AUTH4D-144 Password sign-in with email-code reset
Password sign-in is opt-in per browser application. A password belongs to the tenant’s email
identity for an address, so it is another way to prove that identity. Success returns the same
one-time identity proof as an email code (proof_purpose email_verification). The hosted login
passes that proof to /oauth/authorize/continue and to the guest upgrade endpoints unchanged. A
first password and a reset both go through a 6-digit email code. The API never sends a link that
carries a secret. This adds a hosted-login screen; the OAuth password grant is still rejected.
Availability
All four endpoints below need:
- an active customer tenant;
- an enabled
browserclient withclients.password_enabled = 1; - email sign-in turned on for the project, because resets are delivered by email;
- the
__Host-auth4_flow_{flowId}cookie of a live/oauth/authorizeflow for that tenant and client, as for the email-code endpoints; Originequal to the auth origin (403 access_deniedotherwise).
A client that has not opted in, a native or server client, or a project with email off answers
400 invalid_request. A missing or mismatched flow cookie answers 403 access_denied. Every
response is JSON with Cache-Control: no-store. Device sign-in and native guest-linking flows do not
offer passwords yet.
Sign in
POST /t/{tenantId}/password/sign-in
{ "client_id": "cli_…", "browser_flow_id": "flw_…", "email": "player@example.com", "password": "…" }
200 returns {"proof": {"proof_id": "prf_…", "expires_in": 300}}. An unknown address, an
address with no password, a disabled user and a wrong password all answer
400 {"error": "invalid_credentials"}. Each of these first verifies against a stored or throwaway
hash with the same parameters, so they take the same time. Addresses are matched after trimming,
NFC normalization and lowercasing, as for email codes. A successful sign-in writes
authentication.password.sign_in.succeeded.
Set or reset with an email code
POST /t/{tenantId}/password/reset/start takes client_id, browser_flow_id and email. It
always answers 202 {"expires_in": 600, "resend_after": 60}, whether the address is known, already
has a password, is new or was throttled. The code is sent with the email-code template and shares
the email-code send budgets: 5 per address and 10 per IP an hour, one resend a minute, plus the
tenant and global caps. A new code replaces the previous one. Codes are bound to the tenant, client,
browser flow, address and the hash of the flow cookie. Only a keyed hash of the code is stored, in
the tenant’s challenge Durable Object.
POST /t/{tenantId}/password/reset/confirm
{
"client_id": "cli_…",
"browser_flow_id": "flw_…",
"email": "player@example.com",
"code": "123456",
"password": "…"
}
The new password is checked before the code is taken, so a rejected password can be replaced and sent again with the same code:
| Reason | Rule |
|---|---|
too_short |
Fewer than 12 characters (Unicode code points, after NFKC). |
too_long |
More than 128 characters. |
breached |
Found by the breached-password range check (see below). |
A rejection answers 400 {"error": "invalid_password", "reason": "…"}. The length rule is checked
first. One email-code attempt is then spent (5 per code and 20 per IP an hour) before the breach
lookup, so the outbound lookup is rate limited. Then the code is checked. A wrong, expired or used code
answers 400 {"error": "invalid_code"}, and a wrong guess keeps the code usable until its budget
runs out. On success the address resolves to the tenant’s email identity. When signup is on
(project_settings.signup_enabled), a new user is created, so this is also how a new player
registers with a password. With signup off, a new address gets invalid_code and nothing is
created. The password is stored and the answer is the same 200 proof as sign-in. The audit event
is authentication.password.set for a first password and authentication.password.reset
otherwise.
Change
POST /t/{tenantId}/password/change takes client_id, browser_flow_id, email,
current_password and password. The current password is checked first, under the sign-in budgets
and with the same invalid_credentials answer. The new password is then checked against the rules
above. Success stores the new hash, writes authentication.password.changed and returns a proof.
Lockout and rate limits
Sign-in and change attempts share three budgets in the tenant’s rate-limit Durable Object. They are spent before any hashing:
- 30 attempts per IP address an hour.
- 10 attempts per address from one IP address in 15 minutes. This is the lockout. Guesses from one network do not lock the player out from another (AUTH4D-336).
- 100 attempts per address in 15 minutes, from any IP address. This ceiling is a backstop against guessing spread over many networks.
The two address budgets behave the same for unknown addresses and lift on their own. A successful
sign-in, change or email-code reset also lifts the address’s ceiling and the lockout for the IP
address it came from. See AUTH4D-336 (repository file docs/api-spec/auth4d-336.md).
Over either budget the answer is 429 {"error": "rate_limited"} with Retry-After in seconds.
Budget keys are HMACs under AUTH_PRIVACY_KEY and include the tenant ID.
Hashing
Hashes use WebCrypto PBKDF2-HMAC-SHA256 with a 16-byte random salt and a 256-bit output. OWASP’s current figure for PBKDF2-HMAC-SHA256 is 600,000 iterations. Cloudflare’s WebCrypto accepts at most 100,000 iterations per call, so six stages of 100,000 iterations are chained. Each stage keys PBKDF2 with the previous stage’s output and appends the stage number to the salt. Every stage depends on the one before, so an attacker must compute 600,000 sequential HMAC-SHA256 calls per guess, the same as one 600,000-iteration run. The output is not interchangeable with standard PBKDF2, so the stored value names its parameters:
pbkdf2-sha256$6x100000$<salt, base64url>$<hash, base64url>
Verification uses the stored parameters, which must fall within 1–12 stages and 1,000–100,000 iterations, and compares in constant time. A hash below the current work factor is replaced after the next successful sign-in. Passwords are NFKC-normalized before hashing. On local workerd one 6 × 100,000 hash takes about 190 ms of CPU, which is within the Workers Standard CPU limit. Measuring it on staging is an open follow-up; see below. Argon2id needs a WASM build and a lockfile change, so it is left as a later option. The algorithm prefix leaves room for it.
Breached-password check
New passwords are checked against the Pwned Passwords range API (https://api.pwnedpasswords.com)
with k-anonymity. The Worker sends only the first five hex characters of the password’s SHA-1, with
Add-Padding: true, and compares the returned suffixes itself. If the service cannot be reached, or
answers with an error within three seconds, the check is skipped and the password is accepted, so a
third-party outage cannot block resets. RUNTIME_ENV=local uses a local adapter with a small fixed
list and makes no network calls. Tests inject their own adapter.
Login context
GET /t/{tenantId}/login/context adds "password": true to login_methods only for an opted-in
browser application while email sign-in is on. Otherwise the key is absent and the response is
unchanged.
Per-application setting
GET /management/tenants/{tenantId}/clients/{clientId}/password-sign-in (scope client:read, any
project role) and PATCH with {"enabled": true|false} (scope client:write, owner or admin)
return {"password_sign_in": {"enabled": …}}. Enabling a non-browser client answers
400 validation_failed. A viewer gets 403 insufficient_role, and an unknown or foreign project
gets 403 forbidden. Changing the setting writes management.client.password_sign_in.updated with
status enabled or disabled, in the same D1 batch as the update. The console BFF forwards this
path.
Storage
Migration 0022_password_sign_in.sql is additive. It adds clients.password_enabled (default 0,
browser clients only) and password_credentials (tenant_id, identity_id, password_hash, created_at, updated_at), keyed by tenant and email identity. The table has no foreign key to identities, so
a rebuild of that table cannot cascade-delete passwords. Instead, a trigger deletes the password
when its identity is deleted, including through user deletion. A guest upgrade that moves the email
identity takes the password with it. Audit metadata holds only client_id and provider, never a
password, hash or address.
Not yet covered
- Existing sessions and refresh tokens are not revoked when a password is reset or changed.
- Device sign-in and native guest-linking flows offer email codes, Discord and Steam, not passwords.
- The CPU cost has not yet been measured on staging.
Source: docs/api-spec/auth4d-144.md