captured core.sign-in-page

Sign in with a password in the browser

The sign-in page QAuth renders itself, at /ui/login. /oauth/authorize and the consent screen send a browser here with return_to=/ui/resume/<handle> when it has no session, after parking the authorization request under that handle. The GET renders the form with a CSRF value, mirrored in a signed cookie. The POST checks the pair, refuses a locked-out address, counts the attempt, reserves it on the account's failure ladder, checks the password, the verified-account gate and the enabled flag, and refuses every failure with the same 401 page. On success it creates a new browser session, sets its cookie, clears the CSRF cookie and redirects to return_to when it is a same-origin path, else to /. The resume step swaps the handle back for the parked authorize URL, once.

What this view shows

GET and POST /ui/login and GET /ui/resume/{handle} from the request to the response, in the order the server runs its checks.

Open world: what is not drawn here is not ruled out. Only the guarantees below are binding.

Left out of this view:

The main story

When: The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL.

  1. Wait for a sign-in request: GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address is under its limit
  4. Check the query against the page's schema: the query matches the schema
  5. Read the login-CSRF cookie: the page has its CSRF value
  6. Show the sign-in page: the user types the address and the password, and the browser posts the form
  7. Find the realm of the host: the request has its realm
  8. Count the request against the login limit: the address is under its limit
  9. Check the form against the login form schema: the form matches the schema
  10. Read the login-CSRF cookie: the cookie is absent or decodes
  11. Check the posted CSRF value against the cookie: the posted value is the cookie's
  12. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  13. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  14. Check the password: the password is right
  15. Apply the verified-account gate: the address is confirmed, or the gate is off
  16. Read the user, for its enabled flag: the user is enabled
  17. Settle the counted attempt: the attempt is settled
  18. Create a new browser session: the session is created, with a new sid; no cookie the request carried is read
  19. Record the time of the sign-in on the user: lastLoginAt is written
  20. Record the sign-in: return_to resumes an authorization request; the session cookie is set and the login-CSRF one cleared
  21. Send the signed-in browser back to its authorization request: the browser follows the redirect to GET /ui/resume/<handle>
  22. Find the realm of the host: the request has its realm
  23. Count the request against the global limit: the address is under its limit
  24. Swap the handle for the parked authorize URL, and burn it: the handle is deleted, and its authorize URL read back

Result: Resume the authorization request.

The flow

GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly POST /ui/login arrives for a page this walk did not render GET /ui/resume/<handle> arrives for a handle this walk did not sign in for the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its requests the address is under its limit the query does not match the schema the query matches the schema the login-CSRF cookie cannot be decoded the page has its CSRF value the user types the address and the password, and the browser posts the form the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its login requests the address is under its limit the form does not match the schema the form matches the schema the login-CSRF cookie cannot be decoded the cookie is absent or decodes the cookie is missing, forged or holds another value the posted value is the cookie's an identifier is locked out; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out the attempt is counted on the address being signed in to and on the client address the ladder cannot answer; the floor is waited out, then user.sign_in_failed is recorded with the reason internal_error the account is inside its wait; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out the attempt is counted on the account's ladder, and the next wait set no credential has this address; the decoy hash is verified, and the floor waited out the password does not verify against the hash; the floor is waited out the password is right the address is not confirmed, and the gate is on; the floor is waited out the address is confirmed, or the gate is off the user is disabled; the floor is waited out the user is enabled the attempt is settled the session is created, with a new sid; no cookie the request carried is read lastLoginAt is written return_to resumes an authorization request; the session cookie is set and the login-CSRF one cleared return_to is a same-origin path; the session cookie is set and the login-CSRF one cleared return_to is absent or unsafe, so the browser goes to /; the session cookie is set and the login-CSRF one cleared the browser follows the redirect to GET /ui/resume/<handle> the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its requests the address is under its limit the handle names no live authorization request fails: the store cannot be read or the handle not deleted, which is answered as a spent handle, never a 500 the handle is deleted, and its authorize URL read back start idle Wait for a sign-in request idle · server step The realm resolver's onRequest hook, before any route code. /ui/login is a browser route, so it answers on the realm's primary host only. A request refused here is never counted by the rate limiter. Find the realm of the host showResolvingRealm · server step The GET has no limit of its own: RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW seconds, for each client address and realm, shared with every route that sets none. Count the request against the global limit showRateLimiting · server step return_to is optional, and one string. A repeated return_to arrives as a list, which the schema refuses. Check the query against the page's schema showValidatingQuery · server step A login-CSRF cookie whose signature verifies is reused, so a page refreshed or opened in a second tab keeps matching the cookie; otherwise a new value of 32 random bytes is minted and set. return_to is checked here too, and an unsafe one is replaced by / on the page. Read the login-CSRF cookie showReadingCsrfCookie · server step The form asks for an email address and a password, and carries return_to and the CSRF value as hidden fields. The same value is in the signed cookie, as <kid>.<value>.<mac>: the HMAC-SHA256, under the realm's cookie key that kid names, of the cookie's name, the realm id, the kid and the value, which a page of another site can neither read nor forge. Show the sign-in page rendered · server step The realm resolver's onRequest hook, as for the GET. Find the realm of the host submitResolvingRealm · server step LOGIN_RATE_LIMIT requests per LOGIN_RATE_WINDOW seconds, for each client address and realm, whatever the request ends in. This is the limiter, not the lockout: it counts requests, before the form is read, and its refusal is the error handler's problem document, not the page. Count the request against the login limit submitRateLimiting · server step loginFormSchema in apps/auth-server/src/app/schemas/auth.ts: email of 1 to 254 characters, with no format check, so that the refusal page can show what was typed; a password of at least one character, bounded; return_to optional; csrf_token of at least one character. Check the form against the login form schema submitValidatingForm · server step return_to is checked here too, and an unsafe one becomes /. Read the login-CSRF cookie readingCsrfCookie · server step The signed double-submit defence against login CSRF: the cookie's signature, then a timing-safe comparison of its value with csrf_token. It runs before anything reads the database, so a forged post never reaches a password check; request.csrf_rejected is recorded. Check the posted CSRF value against the cookie checkingCsrf · server step Two identifiers, both kept per realm and shared with POST /auth/login: the hash of the normalized address, and the client address (an IPv6 one by its /64). A locked-out identifier refuses the attempt before anything is counted. Otherwise the attempt is counted on both before the password is looked at, and the attempt that reaches the realm's bruteForce.maxAttempts arms the lockout on its way in, so that guesses sent at once cannot outrun the counter. Refuse a locked-out address, else count the attempt checkingLockout · server step The per-account failure ladder (#615, ADR-017 Decision 7), beside the lock and shared with POST /auth/login and the sign-in engine. One Redis script, keyed on the keyed HMAC of the realm and the normalized address whether or not an account holds it, reads the account's wait, refuses an attempt inside it, and otherwise counts the attempt as a failure and sets the next wait, so guesses sent at once meet the ladder as guesses sent one at a time. An address that signed in to the account in the last 30 days skips the wait while fewer than 20 of the run's counted failures came from such addresses, and is still counted; from then on it waits as any other address does, since many clients may share it (#968). Any error of the store, or of the cookie key the HMACs are derived from, refuses the attempt before a credential is read. Reserve the attempt on the account's failure ladder reservingAttempt · server step The password credential of the normalized address in this realm, then Argon2id against its hash. With no credential the password is verified against a decoy hash, so every outcome costs one verification. Check the password checkingPassword · server step F-08: under the realm's accounts.requireVerifiedAccount (#528), a password account is used only once its address is confirmed. It is read from the credential, before the users row, so an unconfirmed disabled account is refused here. Apply the verified-account gate checkingVerified · server step findingUser Read the user, for its enabled flag findingUser · server step The address the password proved gets its counter and its lockout cleared. The client address gets back only this attempt's own count, and the lockout if this attempt armed it: the failures other attempts left there stay until the window expires, so signing in to an account of one's own does not wipe a password spray from the same address. On the failure ladder the password's counter is cleared, the client address becomes known to the account for 30 days, and a disabled mark the unarmed hard action ignored is cleared. Best effort: a cache failure here is logged and ignored. Settle the counted attempt clearingFailures · server step Always a new session with a new sid, whatever cookie the browser sent: the session-fixation defence. The session service stores it in Postgres with the request's address and User-Agent and the sign-in time, where /auth/logout finds it by its user (#912), and the signed-in address is kept in the session's side record for the consent screen. Create a new browser session creatingSession · server step updatingLastLogin Record the time of the sign-in on the user updatingLastLogin · server step The event urn:qauth:event:user.signed_in:v1-rc.0, with the method password. Record the sign-in recordingSignIn · server step The success answer of the POST, as for signed_in, with /ui/resume/<handle> as its Location. The browser follows it at once. Send the signed-in browser back to its authorization request redirectedToResume · server step The realm resolver's onRequest hook. /ui/resume is a browser route too. Find the realm of the host resumeResolvingRealm · server step The resume has no limit of its own, as for GET /ui/login. Count the request against the global limit resumeRateLimiting · server step A handle that is not 43 base64url characters is refused without reading the store. Otherwise the record is read and deleted at once, before its value is looked at, so a handle resumes once; the value is then checked again to be this server's own /oauth/authorize URL. Every miss that reaches the handler, a store failure included, is the same 400 page: a distinct answer would tell which handles ever existed, and there is no safe place to redirect to. Swap the handle for the parked authorize URL, and burn it consumingHandle · server step /oauth/authorize then checks the client, the redirect URI, the scopes, PKCE and the step-up from scratch, as on a first visit, now with the new session: resuming grants nothing a fresh request would not. Resume the authorization request ends here The session cookie is set, and the login-CSRF cookie is cleared now that the sign-in succeeded. Send the signed-in browser to return_to ends here return_to was absent, or isSafeReturnTo refused it: an absolute URL, a protocol-relative one (//host or /\host), one with a character the URL parser drops, one that resolves to another origin, or one over 16 KiB. QAuth is not an open redirector. Send the signed-in browser to / ends here The form is shown again with a new CSRF value, set in a new cookie, so the user can retry; the typed address is kept and the password is not. No password is checked and nothing is counted. Refuse a form whose CSRF value is not the cookie's, and offer a fresh one ends here Not counted, and no password is checked, even the right one. The answer waits out the 500 ms floor that every refused credential waits, so it takes as long as a wrong password. Distinct from the login limit, which is the rate limiter's problem document. Refuse a locked-out address with the form ends here Not counted, and no password is checked, even the right one; the answer waits out the 500 ms floor. The page is the lockout's: it does not say which of the two refused the attempt. Refuse an attempt inside the account's wait with the form ends here Fail closed: no credential is read and no password checked. The answer waits out the 500 ms floor, and the error is logged for operators. Refuse the attempt when the failure ladder cannot answer ends here user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse an address with no account, like a wrong password ends here user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse a wrong password ends here user.sign_in_failed is recorded with the reason email_not_verified and the user as its subject; the answer is the wrong password's (it was a 403 of its own until ANT-2026-SPEMY9GU, which let anyone tell a free address from a registered one). The attempt stays counted, and the answer waits out the 500 ms floor. Refuse an unconfirmed account under the gate, like a wrong password ends here The password was right. user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse a disabled user, like a wrong password ends here Unknown, malformed (of 1 to 100 characters), expired and already resumed handles answer the same, and so does a store that cannot read or delete the handle, with no link and no redirect: the user starts again from the application, which still holds the PKCE verifier. Tell the user the authorization request has expired ends here validation_error.query Refuse a query that does not match the page's schema ends here The error handler's problem document, not the page; nothing is counted against the lockout. Refuse a form that does not match the login form schema ends here misdirected_request.host Refuse a host that serves no browser route ends here too_many_requests.global_limit Refuse a GET over the global limit ends here The rate limiter's problem document, not the page; distinct from the lockout. Refuse a POST over the login limit ends here The URIError reaches the error handler as an unknown error, logged at error level. The body says nothing of the failure, in any NODE_ENV; its trace_id finds the log line. Fail on a login-CSRF cookie that cannot be decoded ends here
GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly POST /ui/login arrives for a page this walk did not render GET /ui/resume/<handle> arrives for a handle this walk did not sign in for the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its requests the address is under its limit the query does not match the schema the query matches the schema the login-CSRF cookie cannot be decoded the page has its CSRF value the user types the address and the password, and the browser posts the form the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its login requests the address is under its limit the form does not match the schema the form matches the schema the login-CSRF cookie cannot be decoded the cookie is absent or decodes the cookie is missing, forged or holds another value the posted value is the cookie's an identifier is locked out; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out the attempt is counted on the address being signed in to and on the client address the ladder cannot answer; the floor is waited out, then user.sign_in_failed is recorded with the reason internal_error the account is inside its wait; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out the attempt is counted on the account's ladder, and the next wait set no credential has this address; the decoy hash is verified, and the floor waited out the password does not verify against the hash; the floor is waited out the password is right fails: the database fails the address is not confirmed, and the gate is on; the floor is waited out the address is confirmed, or the gate is off the user is disabled; the floor is waited out the user is enabled fails: the database fails the attempt is settled the session is created, with a new sid; no cookie the request carried is read fails: the session store fails lastLoginAt is written fails: the database fails return_to resumes an authorization request; the session cookie is set and the login-CSRF one cleared return_to is a same-origin path; the session cookie is set and the login-CSRF one cleared return_to is absent or unsafe, so the browser goes to /; the session cookie is set and the login-CSRF one cleared fails: the event store fails the browser follows the redirect to GET /ui/resume/<handle> the host names no enabled realm with an issuer the host is one of the realm's endpoint hosts the request has its realm the address has used up its requests the address is under its limit the handle names no live authorization request fails: the store cannot be read or the handle not deleted, which is answered as a spent handle, never a 500 the handle is deleted, and its authorize URL read back start idle Wait for a sign-in request idle · server step The realm resolver's onRequest hook, before any route code. /ui/login is a browser route, so it answers on the realm's primary host only. A request refused here is never counted by the rate limiter. Find the realm of the host showResolvingRealm · server step The GET has no limit of its own: RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW seconds, for each client address and realm, shared with every route that sets none. Count the request against the global limit showRateLimiting · server step return_to is optional, and one string. A repeated return_to arrives as a list, which the schema refuses. Check the query against the page's schema showValidatingQuery · server step A login-CSRF cookie whose signature verifies is reused, so a page refreshed or opened in a second tab keeps matching the cookie; otherwise a new value of 32 random bytes is minted and set. return_to is checked here too, and an unsafe one is replaced by / on the page. Read the login-CSRF cookie showReadingCsrfCookie · server step The form asks for an email address and a password, and carries return_to and the CSRF value as hidden fields. The same value is in the signed cookie, as <kid>.<value>.<mac>: the HMAC-SHA256, under the realm's cookie key that kid names, of the cookie's name, the realm id, the kid and the value, which a page of another site can neither read nor forge. Show the sign-in page rendered · server step The realm resolver's onRequest hook, as for the GET. Find the realm of the host submitResolvingRealm · server step LOGIN_RATE_LIMIT requests per LOGIN_RATE_WINDOW seconds, for each client address and realm, whatever the request ends in. This is the limiter, not the lockout: it counts requests, before the form is read, and its refusal is the error handler's problem document, not the page. Count the request against the login limit submitRateLimiting · server step loginFormSchema in apps/auth-server/src/app/schemas/auth.ts: email of 1 to 254 characters, with no format check, so that the refusal page can show what was typed; a password of at least one character, bounded; return_to optional; csrf_token of at least one character. Check the form against the login form schema submitValidatingForm · server step return_to is checked here too, and an unsafe one becomes /. Read the login-CSRF cookie readingCsrfCookie · server step The signed double-submit defence against login CSRF: the cookie's signature, then a timing-safe comparison of its value with csrf_token. It runs before anything reads the database, so a forged post never reaches a password check; request.csrf_rejected is recorded. Check the posted CSRF value against the cookie checkingCsrf · server step Two identifiers, both kept per realm and shared with POST /auth/login: the hash of the normalized address, and the client address (an IPv6 one by its /64). A locked-out identifier refuses the attempt before anything is counted. Otherwise the attempt is counted on both before the password is looked at, and the attempt that reaches the realm's bruteForce.maxAttempts arms the lockout on its way in, so that guesses sent at once cannot outrun the counter. Refuse a locked-out address, else count the attempt checkingLockout · server step The per-account failure ladder (#615, ADR-017 Decision 7), beside the lock and shared with POST /auth/login and the sign-in engine. One Redis script, keyed on the keyed HMAC of the realm and the normalized address whether or not an account holds it, reads the account's wait, refuses an attempt inside it, and otherwise counts the attempt as a failure and sets the next wait, so guesses sent at once meet the ladder as guesses sent one at a time. An address that signed in to the account in the last 30 days skips the wait while fewer than 20 of the run's counted failures came from such addresses, and is still counted; from then on it waits as any other address does, since many clients may share it (#968). Any error of the store, or of the cookie key the HMACs are derived from, refuses the attempt before a credential is read. Reserve the attempt on the account's failure ladder reservingAttempt · server step The password credential of the normalized address in this realm, then Argon2id against its hash. With no credential the password is verified against a decoy hash, so every outcome costs one verification. Check the password checkingPassword · server step F-08: under the realm's accounts.requireVerifiedAccount (#528), a password account is used only once its address is confirmed. It is read from the credential, before the users row, so an unconfirmed disabled account is refused here. Apply the verified-account gate checkingVerified · server step findingUser Read the user, for its enabled flag findingUser · server step The address the password proved gets its counter and its lockout cleared. The client address gets back only this attempt's own count, and the lockout if this attempt armed it: the failures other attempts left there stay until the window expires, so signing in to an account of one's own does not wipe a password spray from the same address. On the failure ladder the password's counter is cleared, the client address becomes known to the account for 30 days, and a disabled mark the unarmed hard action ignored is cleared. Best effort: a cache failure here is logged and ignored. Settle the counted attempt clearingFailures · server step Always a new session with a new sid, whatever cookie the browser sent: the session-fixation defence. The session service stores it in Postgres with the request's address and User-Agent and the sign-in time, where /auth/logout finds it by its user (#912), and the signed-in address is kept in the session's side record for the consent screen. Create a new browser session creatingSession · server step updatingLastLogin Record the time of the sign-in on the user updatingLastLogin · server step The event urn:qauth:event:user.signed_in:v1-rc.0, with the method password. Record the sign-in recordingSignIn · server step The success answer of the POST, as for signed_in, with /ui/resume/<handle> as its Location. The browser follows it at once. Send the signed-in browser back to its authorization request redirectedToResume · server step The realm resolver's onRequest hook. /ui/resume is a browser route too. Find the realm of the host resumeResolvingRealm · server step The resume has no limit of its own, as for GET /ui/login. Count the request against the global limit resumeRateLimiting · server step A handle that is not 43 base64url characters is refused without reading the store. Otherwise the record is read and deleted at once, before its value is looked at, so a handle resumes once; the value is then checked again to be this server's own /oauth/authorize URL. Every miss that reaches the handler, a store failure included, is the same 400 page: a distinct answer would tell which handles ever existed, and there is no safe place to redirect to. Swap the handle for the parked authorize URL, and burn it consumingHandle · server step /oauth/authorize then checks the client, the redirect URI, the scopes, PKCE and the step-up from scratch, as on a first visit, now with the new session: resuming grants nothing a fresh request would not. Resume the authorization request ends here The session cookie is set, and the login-CSRF cookie is cleared now that the sign-in succeeded. Send the signed-in browser to return_to ends here return_to was absent, or isSafeReturnTo refused it: an absolute URL, a protocol-relative one (//host or /\host), one with a character the URL parser drops, one that resolves to another origin, or one over 16 KiB. QAuth is not an open redirector. Send the signed-in browser to / ends here The form is shown again with a new CSRF value, set in a new cookie, so the user can retry; the typed address is kept and the password is not. No password is checked and nothing is counted. Refuse a form whose CSRF value is not the cookie's, and offer a fresh one ends here Not counted, and no password is checked, even the right one. The answer waits out the 500 ms floor that every refused credential waits, so it takes as long as a wrong password. Distinct from the login limit, which is the rate limiter's problem document. Refuse a locked-out address with the form ends here Not counted, and no password is checked, even the right one; the answer waits out the 500 ms floor. The page is the lockout's: it does not say which of the two refused the attempt. Refuse an attempt inside the account's wait with the form ends here Fail closed: no credential is read and no password checked. The answer waits out the 500 ms floor, and the error is logged for operators. Refuse the attempt when the failure ladder cannot answer ends here user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse an address with no account, like a wrong password ends here user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse a wrong password ends here user.sign_in_failed is recorded with the reason email_not_verified and the user as its subject; the answer is the wrong password's (it was a 403 of its own until ANT-2026-SPEMY9GU, which let anyone tell a free address from a registered one). The attempt stays counted, and the answer waits out the 500 ms floor. Refuse an unconfirmed account under the gate, like a wrong password ends here The password was right. user.sign_in_failed is recorded with the reason invalid_credentials and no subject. The attempt stays counted, and the answer waits out the 500 ms floor. Refuse a disabled user, like a wrong password ends here Unknown, malformed (of 1 to 100 characters), expired and already resumed handles answer the same, and so does a store that cannot read or delete the handle, with no link and no redirect: the user starts again from the application, which still holds the PKCE verifier. Tell the user the authorization request has expired ends here validation_error.query Refuse a query that does not match the page's schema ends here The error handler's problem document, not the page; nothing is counted against the lockout. Refuse a form that does not match the login form schema ends here misdirected_request.host Refuse a host that serves no browser route ends here too_many_requests.global_limit Refuse a GET over the global limit ends here The rate limiter's problem document, not the page; distinct from the lockout. Refuse a POST over the login limit ends here The URIError reaches the error handler as an unknown error, logged at error level. The body says nothing of the failure, in any NODE_ENV; its trace_id finds the log line. Fail on a login-CSRF cookie that cannot be decoded ends here The steps before the failing one stay done: a session created before a later failure stays, though the browser never receives its cookie. The body says nothing of the failure, in any NODE_ENV; its trace_id finds the log line. Fail on a store error ends here

blue line: the main storyred dashed line: a failure path (shown with “Show failure paths”)dotted line: decided by a rule, nobody actsdashed grey box: the server decides, nothing is shown

What each step answers

StepThe outside world sees
Show the sign-in page
rendered
200 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store","Content-Security-Policy":"the global policy, whose style-src carries this response's nonce","Set-Cookie":"__Host-qauth_login_csrf=<kid>.<value>.<mac>; Path=/; HttpOnly; SameSite=Lax; Max-Age=SESSION_COOKIE_TTL, with Secure when SESSION_COOKIE_SECURE is on; sent only when the request carried no validly signed one"} · body: {"title":"Sign in — QAuth","form":{"method":"post","action":"/ui/login","fields":{"return_to":"the query's return_to when it is safe, else / (hidden)","csrf_token":"the cookie's CSRF value (hidden)","email":"type email, autocomplete username, empty","password":"type password, autocomplete current-password"}},"links":"Sign in with your wallet, only when wallet federation can serve the flow"}
Send the signed-in browser back to its authorization request
redirectedToResume
302 · headers: {"Location":"/ui/resume/<handle>","Cache-Control":"no-store","Set-Cookie":"two headers: __Host-qauth_session=<kid>.<payload naming sid>.<mac>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<the seconds left until the session's absolute end>, and __Host-qauth_login_csrf=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0, each with Secure when SESSION_COOKIE_SECURE is on"}
Resume the authorization request
authorization_resumed
302 · headers: {"Location":"/oauth/authorize?<the parameters of the parked request>","Cache-Control":"no-store"}
Send the signed-in browser to return_to
signed_in
302 · headers: {"Location":"return_to, as posted","Cache-Control":"no-store","Set-Cookie":"two headers: __Host-qauth_session=<kid>.<payload naming sid>.<mac>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<the seconds left until the session's absolute end>, and __Host-qauth_login_csrf=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0, each with Secure when SESSION_COOKIE_SECURE is on"}
Send the signed-in browser to /
signed_in.to_root
302 · headers: {"Location":"/","Cache-Control":"no-store","Set-Cookie":"two headers: __Host-qauth_session=<kid>.<payload naming sid>.<mac>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<the seconds left until the session's absolute end>, and __Host-qauth_login_csrf=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0, each with Secure when SESSION_COOKIE_SECURE is on"}
Refuse a form whose CSRF value is not the cookie's, and offer a fresh one
forbidden.csrf_mismatch
403 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store","Set-Cookie":"__Host-qauth_login_csrf=<kid>.<a new value>.<mac>; Path=/; HttpOnly; SameSite=Lax; Max-Age=SESSION_COOKIE_TTL, with Secure when SESSION_COOKIE_SECURE is on"} · body: the sign-in form again, with the error "Your session expired. Please try again.", the typed address, return_to (/ when unsafe) and the new CSRF value
Refuse a locked-out address with the form
too_many_requests.locked_out
429 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store","Retry-After":"the seconds left on the lockout; the realm's full bruteForce.lockoutSeconds for an attempt that lost the race to arm it"} · body: the sign-in form again, with the error "Too many failed sign-in attempts. Please try again later.", the typed address, return_to (/ when unsafe) and the CSRF value of the cookie, which stays set
Refuse an attempt inside the account's wait with the form
too_many_requests.ladder_wait
429 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store","Retry-After":"the seconds left on the account's wait, rounded up"} · body: the sign-in form again, with the error "Too many failed sign-in attempts. Please try again later.", the typed address, return_to (/ when unsafe) and the CSRF value of the cookie, which stays set
Refuse the attempt when the failure ladder cannot answer
service_unavailable.ladder
503 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store","Retry-After":"5"} · body: the sign-in form again, with the error "Sign-in is unavailable right now. Please try again shortly.", the typed address, return_to (/ when unsafe) and the CSRF value of the cookie, which stays set
Refuse an address with no account, like a wrong password
invalid_credentials.unknown_identifier
401 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"} · body: the sign-in form again, with the typed address, return_to (/ when unsafe), the CSRF value of the cookie, which stays set, and the error "Invalid email or password." While the realm's accounts.requireVerifiedAccount is on, the error goes on: "If you have just registered, confirm your email address first with the link we sent you."
Refuse a wrong password
invalid_credentials.wrong_password
401 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"} · body: the sign-in form again, with the typed address, return_to (/ when unsafe), the CSRF value of the cookie, which stays set, and the error "Invalid email or password." While the realm's accounts.requireVerifiedAccount is on, the error goes on: "If you have just registered, confirm your email address first with the link we sent you."
Refuse an unconfirmed account under the gate, like a wrong password
invalid_credentials.email_not_verified
401 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"} · body: the sign-in form again, with the typed address, return_to (/ when unsafe), the CSRF value of the cookie, which stays set, and the error "Invalid email or password." While the realm's accounts.requireVerifiedAccount is on, the error goes on: "If you have just registered, confirm your email address first with the link we sent you."
Refuse a disabled user, like a wrong password
invalid_credentials.account_disabled
401 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"} · body: the sign-in form again, with the typed address, return_to (/ when unsafe), the CSRF value of the cookie, which stays set, and the error "Invalid email or password." While the realm's accounts.requireVerifiedAccount is on, the error goes on: "If you have just registered, confirm your email address first with the link we sent you."
Tell the user the authorization request has expired
bad_request.handle_spent
400 · headers: {"Content-Type":"text/html; charset=utf-8","Cache-Control":"no-store"} · body: a page titled "Sign-in request expired — QAuth" that says "This sign-in request has expired", that the user is signed in but the authorization request is no longer valid, and to return to the application and start again
Refuse a query that does not match the page's schema
validation_error.query
400 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#validation-error","title":"Validation error","status":400,"detail":"The request is not valid.","instance":"/ui/login","code":"validation_error","trace_id":"the request id","errors":[{"pointer":"/return_to","message":"the schema's message, such as: Invalid input: expected string, received array"}]}
Refuse a form that does not match the login form schema
validation_error.form
400 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#validation-error","title":"Validation error","status":400,"detail":"The request is not valid.","instance":"/ui/login","code":"validation_error","trace_id":"the request id","errors":[{"pointer":"/<the field>, such as /password","message":"the schema's message"}]}
Refuse a host that serves no browser route
misdirected_request.host
421 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#misdirected-request","title":"Misdirected request","status":421,"detail":"This server does not answer for the requested host","instance":"/ui/login, or /ui/resume/<handle>","code":"misdirected_request","trace_id":"the request id"}
Refuse a GET over the global limit
too_many_requests.global_limit
429 · headers: {"x-ratelimit-limit":"RATE_LIMIT_MAX","x-ratelimit-remaining":"0","x-ratelimit-reset":"the seconds until the window resets","retry-after":"the same seconds"} · body: {"type":"https://docs.qauth.dev/reference/problem-types/#too-many-requests","title":"Too many requests","status":429,"detail":"Rate limit exceeded, retry in {the rest of the window, such as 1 minute}","instance":"/ui/login, or /ui/resume/<handle>","code":"too_many_requests","trace_id":"the request id"}
Refuse a POST over the login limit
too_many_requests.login_limit
429 · headers: {"x-ratelimit-limit":"LOGIN_RATE_LIMIT","x-ratelimit-remaining":"0","x-ratelimit-reset":"the seconds until the window resets","retry-after":"the same seconds"} · body: {"type":"https://docs.qauth.dev/reference/problem-types/#too-many-requests","title":"Too many requests","status":429,"detail":"Rate limit exceeded, retry in {the rest of the window, such as 15 minutes}","instance":"/ui/login","code":"too_many_requests","trace_id":"the request id"}
Fail on a login-CSRF cookie that cannot be decoded
server_error.malformed_cookie
500 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#internal-error","title":"Internal error","status":500,"instance":"/ui/login","code":"internal_error","trace_id":"the request id"}
Fail on a store error
server_error.store
500 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#internal-error","title":"Internal error","status":500,"instance":"/ui/login","code":"internal_error","trace_id":"the request id"}

How it decides what happens next

Find the realm of the host

#IfThen
1the host names no enabled realm with an issuerRefuse a host that serves no browser route
2the host is one of the realm's endpoint hostsRefuse a host that serves no browser route
3the request has its realmCount the request against the global limit

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1hostUnknownmisdirected_request.host120 of 1200
2endpointHostmisdirected_request.host120 of 1200
3otherwiseshowRateLimiting960 of 1200
hostUnknown
gate == 'unknownHost'
endpointHost
gate == 'endpointHost'

Count the request against the global limit

#IfThen
1the address has used up its requestsRefuse a GET over the global limit
2the address is under its limitCheck the query against the page's schema

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1rateLimitedtoo_many_requests.global_limit120 of 960
2otherwiseshowValidatingQuery840 of 960
rateLimited
gate == 'rateLimited'

Check the query against the page's schema

#IfThen
1the query does not match the schemaRefuse a query that does not match the page's schema
2the query matches the schemaRead the login-CSRF cookie

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1inputInvalidvalidation_error.query120 of 840
2otherwiseshowReadingCsrfCookie720 of 840
inputInvalid
gate == 'invalidInput'

Read the login-CSRF cookie

#IfThen
1the login-CSRF cookie cannot be decodedFail on a login-CSRF cookie that cannot be decoded
2the page has its CSRF valueShow the sign-in page

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1cookieMalformedserver_error.malformed_cookie120 of 720
2otherwiserendered600 of 720
cookieMalformed
gate == 'malformedCookie'

Find the realm of the host

#IfThen
1the host names no enabled realm with an issuerRefuse a host that serves no browser route
2the host is one of the realm's endpoint hostsRefuse a host that serves no browser route
3the request has its realmCount the request against the login limit

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1hostUnknownmisdirected_request.host120 of 1200
2endpointHostmisdirected_request.host120 of 1200
3otherwisesubmitRateLimiting960 of 1200
hostUnknown
gate == 'unknownHost'
endpointHost
gate == 'endpointHost'

Count the request against the login limit

#IfThen
1the address has used up its login requestsRefuse a POST over the login limit
2the address is under its limitCheck the form against the login form schema

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1rateLimitedtoo_many_requests.login_limit120 of 960
2otherwisesubmitValidatingForm840 of 960
rateLimited
gate == 'rateLimited'

Check the form against the login form schema

#IfThen
1the form does not match the schemaRefuse a form that does not match the login form schema
2the form matches the schemaRead the login-CSRF cookie

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1inputInvalidvalidation_error.form120 of 840
2otherwisereadingCsrfCookie720 of 840
inputInvalid
gate == 'invalidInput'

Read the login-CSRF cookie

#IfThen
1the login-CSRF cookie cannot be decodedFail on a login-CSRF cookie that cannot be decoded
2the cookie is absent or decodesCheck the posted CSRF value against the cookie

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1cookieMalformedserver_error.malformed_cookie120 of 720
2otherwisecheckingCsrf600 of 720
cookieMalformed
gate == 'malformedCookie'

Check the posted CSRF value against the cookie

#IfThen
1the cookie is missing, forged or holds another valueRefuse a form whose CSRF value is not the cookie's, and offer a fresh one
2the posted value is the cookie'sRefuse a locked-out address, else count the attempt

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1csrfMismatchforbidden.csrf_mismatch120 of 600
2otherwisecheckingLockout480 of 600
csrfMismatch
gate == 'csrfMismatch'

Refuse a locked-out address, else count the attempt

#IfThen
1an identifier is locked out; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_outRefuse a locked-out address with the form
2the attempt is counted on the address being signed in to and on the client addressReserve the attempt on the account's failure ladder

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1lockedOuttoo_many_requests.locked_out120 of 480
2otherwisereservingAttempt360 of 480
lockedOut
gate == 'lockedOut'

Reserve the attempt on the account's failure ladder

#IfThen
1the ladder cannot answer; the floor is waited out, then user.sign_in_failed is recorded with the reason internal_errorRefuse the attempt when the failure ladder cannot answer
2the account is inside its wait; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_outRefuse an attempt inside the account's wait with the form
3the attempt is counted on the account's ladder, and the next wait setCheck the password

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1ladderUnavailableservice_unavailable.ladder120 of 360
2ladderWaittoo_many_requests.ladder_wait120 of 360
3otherwisecheckingPassword120 of 360
ladderUnavailable
gate == 'ladderUnavailable'
ladderWait
gate == 'ladderWait'

Apply the verified-account gate

#IfThen
1the address is not confirmed, and the gate is on; the floor is waited outRefuse an unconfirmed account under the gate, like a wrong password
2the address is confirmed, or the gate is offRead the user, for its enabled flag

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1unverifiedUnderTheGateinvalid_credentials.email_not_verified12 of 48
2otherwisefindingUser36 of 48
unverifiedUnderTheGate
account in ['unverified', 'disabledUnverified'] and requireVerified

Settle the counted attempt

#IfThen
1the attempt is settledCreate a new browser session

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1otherwisecreatingSession18 of 18

Find the realm of the host

#IfThen
1the host names no enabled realm with an issuerRefuse a host that serves no browser route
2the host is one of the realm's endpoint hostsRefuse a host that serves no browser route
3the request has its realmCount the request against the global limit

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1hostUnknownmisdirected_request.host120 of 1206
2endpointHostmisdirected_request.host120 of 1206
3otherwiseresumeRateLimiting966 of 1206
hostUnknown
gate == 'unknownHost'
endpointHost
gate == 'endpointHost'

Count the request against the global limit

#IfThen
1the address has used up its requestsRefuse a GET over the global limit
2the address is under its limitSwap the handle for the parked authorize URL, and burn it

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1rateLimitedtoo_many_requests.global_limit120 of 966
2otherwiseconsumingHandle846 of 966
rateLimited
gate == 'rateLimited'

Every way it can end

It ends withWhenWhat happens
Resume the authorization request
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/resume/<handle> arrives for a handle this walk did not sign in for
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address is under its limit
  4. Swap the handle for the parked authorize URL, and burn it: the handle is deleted, and its authorize URL read back
Send the signed-in browser to return_to
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is another same-origin relative path, such as /account, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: the password is right
  10. Apply the verified-account gate: the address is confirmed, or the gate is off
  11. Read the user, for its enabled flag: the user is enabled
  12. Settle the counted attempt: the attempt is settled
  13. Create a new browser session: the session is created, with a new sid; no cookie the request carried is read
  14. Record the time of the sign-in on the user: lastLoginAt is written
  15. Record the sign-in: return_to is a same-origin path; the session cookie is set and the login-CSRF one cleared
Send the signed-in browser to /
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is absent, or not a same-origin relative path: another host, //host, /\host, a control character or a space, more than 16 KiB; the page carries / in its place, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: the password is right
  10. Apply the verified-account gate: the address is confirmed, or the gate is off
  11. Read the user, for its enabled flag: the user is enabled
  12. Settle the counted attempt: the attempt is settled
  13. Create a new browser session: the session is created, with a new sid; no cookie the request carried is read
  14. Record the time of the sign-in on the user: lastLoginAt is written
  15. Record the sign-in: return_to is absent or unsafe, so the browser goes to /; the session cookie is set and the login-CSRF one cleared
Refuse a form whose CSRF value is not the cookie's, and offer a fresh one
ends here
The POST carries no login-CSRF cookie, one whose signature does not verify, or one whose value is not the posted csrf_token, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the cookie is missing, forged or holds another value
Refuse a locked-out address with the form
ends here
The address being signed in to, or the client address, is locked out: the realm's bruteForce.maxAttempts attempts were counted on it within its bruteForce.windowSeconds, and its bruteForce.lockoutSeconds have not passed (#528: the realm's policies, which the FAILED_LOGIN_* keys seed in a new realm); or this attempt reached the threshold together with another one, which armed the lockout first, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: an identifier is locked out; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out
Refuse an attempt inside the account's wait with the form
ends here
The account the address names, held or not, is inside the wait of its failure ladder: from its 5th consecutive counted failure, the n-th makes the next attempt wait 2^(n-5) seconds, at most 15 minutes; and the client address has not signed in to the account in the last 30 days, or 20 of the run's counted failures already came from addresses that have (#968), the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the account is inside its wait; the floor is waited out, then user.sign_in_failed is recorded with the reason locked_out
Refuse the attempt when the failure ladder cannot answer
ends here
The failure ladder's store (Redis) or the realm's cookie key cannot be read in time, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the ladder cannot answer; the floor is waited out, then user.sign_in_failed is recorded with the reason internal_error
Refuse an address with no account, like a wrong password
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has no password credential in this realm, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: no credential has this address; the decoy hash is verified, and the floor waited out
Refuse a wrong password
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, it is another one, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: the password does not verify against the hash; the floor is waited out
Refuse an unconfirmed account under the gate, like a wrong password
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential whose address is not confirmed yet, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: the password is right
  10. Apply the verified-account gate: the address is not confirmed, and the gate is on; the floor is waited out
Refuse a disabled user, like a wrong password
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a confirmed password credential, and its user is disabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: the password is right
  10. Apply the verified-account gate: the address is confirmed, or the gate is off
  11. Read the user, for its enabled flag: the user is disabled; the floor is waited out
Tell the user the authorization request has expired
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/resume/<handle> arrives for a handle this walk did not sign in for
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address is under its limit
  4. Swap the handle for the parked authorize URL, and burn it: it fails, because the store cannot be read or the handle not deleted, which is answered as a spent handle, never a 500
Refuse a query that does not match the page's schema
ends here
The query (GET /ui/login) or the form (POST) does not pass the route schema, such as a repeated return_to, an empty password or a missing csrf_token; GET /ui/resume has no schema that can fail, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address is under its limit
  4. Check the query against the page's schema: the query does not match the schema
Refuse a form that does not match the login form schema
ends here
The query (GET /ui/login) or the form (POST) does not pass the route schema, such as a repeated return_to, an empty password or a missing csrf_token; GET /ui/resume has no schema that can fail, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form does not match the schema
Refuse a host that serves no browser route
ends here
The host names no realm, a disabled realm or a realm with no issuer, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly
  2. Find the realm of the host: the host names no enabled realm with an issuer
Refuse a GET over the global limit
ends here
The address has used up its requests for the window: the global limit for the two GETs, the login limit for the POST, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address has used up its requests
Refuse a POST over the login limit
ends here
The address has used up its requests for the window: the global limit for the two GETs, the login limit for the POST, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address has used up its login requests
Fail on a login-CSRF cookie that cannot be decoded
ends here
The request carries a login-CSRF cookie whose value is not valid percent-encoding, such as %zz, which the cookie reader decodes without a catch; GET /ui/resume reads no cookie, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: GET /ui/login arrives: sent by /oauth/authorize or the consent screen with return_to=/ui/resume/<handle>, or opened directly
  2. Find the realm of the host: the request has its realm
  3. Count the request against the global limit: the address is under its limit
  4. Check the query against the page's schema: the query matches the schema
  5. Read the login-CSRF cookie: the login-CSRF cookie cannot be decoded
Fail on a store error
ends here
The host is the primary host of an enabled realm, the address is under its limit, the query or the form passes the route schema, the login-CSRF cookie decodes, the posted csrf_token matches it, and neither the address being signed in to nor the client address is locked out, the address has a password credential in this realm, its address confirmed, and its user is enabled, the posted password is the credential's, the realm's accounts.requireVerifiedAccount is on, the default (#528); REQUIRE_VERIFIED_ACCOUNT unset, blank or true seeds it so in a new realm, return_to is /ui/resume/<handle>, as /oauth/authorize and the consent screen send it, the handle names an authorization request parked less than ten minutes ago that no request has resumed yet, and the parked value is an /oauth/authorize URL
  1. Wait for a sign-in request: POST /ui/login arrives for a page this walk did not render
  2. Find the realm of the host: the request has its realm
  3. Count the request against the login limit: the address is under its limit
  4. Check the form against the login form schema: the form matches the schema
  5. Read the login-CSRF cookie: the cookie is absent or decodes
  6. Check the posted CSRF value against the cookie: the posted value is the cookie's
  7. Refuse a locked-out address, else count the attempt: the attempt is counted on the address being signed in to and on the client address
  8. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  9. Check the password: it fails, because the database fails

Each row is also a generated test: set the starting situation, send the events, expect the ending.

What is guaranteed

These hold in all 16821 situations the model can reach, from 1200 different starting situations.

Exact rules
IdNever
no-session-without-the-csrf-pairsessionCreated and gate != 'open'
no-session-for-a-refused-accountsessionCreated and (noCredential or wrongPassword or userDisabled or unverifiedUnderTheGate)
attempt-counted-before-the-passwordstate == 'checkingPassword' and not attemptCounted
attempt-reserved-on-the-ladder-before-the-passwordstate == 'checkingPassword' and not attemptReserved
ladder-refusals-check-no-passwordstate in ['too_many_requests.ladder_wait', 'service_unavailable.ladder'] and (attemptReserved or decoyVerified)
locked-out-checks-no-passwordstate == 'too_many_requests.locked_out' and attemptCounted
csrf-before-the-lockout-and-the-databasegate in ['malformedCookie', 'csrfMismatch'] and (attemptCounted or attemptReserved or state in ['checkingLockout', 'reservingAttempt', 'checkingPassword', 'findingUser'])
every-refused-credential-waits-the-floorstate in ['too_many_requests.locked_out', 'too_many_requests.ladder_wait', 'service_unavailable.ladder', 'invalid_credentials.unknown_identifier', 'invalid_credentials.wrong_password', 'invalid_credentials.email_not_verified', 'invalid_credentials.account_disabled'] and not floorWaited
unknown-address-verifies-the-decoystate == 'invalid_credentials.unknown_identifier' and not decoyVerified
a-session-always-has-a-fresh-sidsessionCreated and not freshSid
success-clears-the-login-csrf-cookiestate in ['redirectedToResume', 'signed_in', 'signed_in.to_root'] and not csrfCookieCleared
only-same-origin-redirectsstate in ['signed_in', 'redirectedToResume'] and returnTo == 'unsafe'
resumed-handle-is-burnedstate == 'authorization_resumed' and not handleBurned

Reference

All transitions (56)
FromEventGuardToEffectsTaken
idleSHOWshowResolvingRealmyes
idleSUBMITsubmitResolvingRealmyes
idleRESUMEresumeResolvingRealmyes
showResolvingRealmrule 1hostUnknownmisdirected_request.hostyes
showResolvingRealmrule 2endpointHostmisdirected_request.hostyes
showResolvingRealmrule 3showRateLimitingyes
showRateLimitingrule 1rateLimitedtoo_many_requests.global_limityes
showRateLimitingrule 2showValidatingQueryyes
showValidatingQueryrule 1inputInvalidvalidation_error.queryyes
showValidatingQueryrule 2showReadingCsrfCookieyes
showReadingCsrfCookierule 1cookieMalformedserver_error.malformed_cookieyes
showReadingCsrfCookierule 2renderedyes
renderedSUBMITsubmitResolvingRealmyes
submitResolvingRealmrule 1hostUnknownmisdirected_request.hostyes
submitResolvingRealmrule 2endpointHostmisdirected_request.hostyes
submitResolvingRealmrule 3submitRateLimitingyes
submitRateLimitingrule 1rateLimitedtoo_many_requests.login_limityes
submitRateLimitingrule 2submitValidatingFormyes
submitValidatingFormrule 1inputInvalidvalidation_error.formyes
submitValidatingFormrule 2readingCsrfCookieyes
readingCsrfCookierule 1cookieMalformedserver_error.malformed_cookieyes
readingCsrfCookierule 2checkingCsrfyes
checkingCsrfrule 1csrfMismatchforbidden.csrf_mismatchyes
checkingCsrfrule 2checkingLockoutyes
checkingLockoutrule 1lockedOuttoo_many_requests.locked_outwaitFlooryes
checkingLockoutrule 2reservingAttemptcountAttemptyes
reservingAttemptrule 1ladderUnavailableservice_unavailable.ladderwaitFlooryes
reservingAttemptrule 2ladderWaittoo_many_requests.ladder_waitwaitFlooryes
reservingAttemptrule 3checkingPasswordreserveAttemptyes
checkingPasswordDONEnoCredentialinvalid_credentials.unknown_identifierverifyDecoy waitFlooryes
checkingPasswordDONEwrongPasswordinvalid_credentials.wrong_passwordwaitFlooryes
checkingPasswordDONEcheckingVerifiedyes
checkingPasswordFAIL.serverserver_error.storeyes
checkingVerifiedrule 1unverifiedUnderTheGateinvalid_credentials.email_not_verifiedwaitFlooryes
checkingVerifiedrule 2findingUseryes
findingUserDONEuserDisabledinvalid_credentials.account_disabledwaitFlooryes
findingUserDONEclearingFailuresyes
findingUserFAIL.serverserver_error.storeyes
clearingFailuresrule 1creatingSessionyes
creatingSessionDONEupdatingLastLogincreateSessionyes
creatingSessionFAIL.serverserver_error.storeyes
updatingLastLoginDONErecordingSignInyes
updatingLastLoginFAIL.serverserver_error.storeyes
recordingSignInDONEresumeReturnredirectedToResumeclearCsrfCookieyes
recordingSignInDONEpathReturnsigned_inclearCsrfCookieyes
recordingSignInDONEsigned_in.to_rootclearCsrfCookieyes
recordingSignInFAIL.serverserver_error.storeyes
redirectedToResumeRESUMEresumeResolvingRealmyes
resumeResolvingRealmrule 1hostUnknownmisdirected_request.hostyes
resumeResolvingRealmrule 2endpointHostmisdirected_request.hostyes
resumeResolvingRealmrule 3resumeRateLimitingyes
resumeRateLimitingrule 1rateLimitedtoo_many_requests.global_limityes
resumeRateLimitingrule 2consumingHandleyes
consumingHandleDONEhandleSpentbad_request.handle_spentyes
consumingHandleDONEauthorization_resumedburnHandleyes
consumingHandleFAIL.storebad_request.handle_spentyes