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 browser client with clients.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/authorize flow for that tenant and client, as for the email-code endpoints;
  • Origin equal to the auth origin (403 access_denied otherwise).

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