captured core.sign-in-token

Sign in with a password for tokens

A caller posts an email address and a password as JSON. QAuth 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. On success it signs an access token for the system client, which carries no scope, stores a new refresh-token family, records the sign-in and answers with both tokens.

What this view shows

POST /auth/login 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 a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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.

  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. Check the password: the password is right
  8. Apply the verified-account gate: the address is confirmed, or the gate is off
  9. Read the user, for its enabled flag: the user is enabled
  10. Sign an access token for the system client: the access token is signed, and a refresh token of 32 random bytes generated
  11. Store the refresh token, hashed, as a new family: the refresh token is stored
  12. Record the sign-in: the event is recorded
  13. Record the time of the sign-in on the user: lastLoginAt is written
  14. Settle the counted attempt: the attempt is settled

Result: Answer with the tokens.

The flow

POST /auth/login arrives with an email address and a password the host names no enabled realm with an issuer the request has its realm the address has used up its login requests the address is under its limit the body does not match the schema the body matches the schema an identifier is locked out; the floor is waited out the attempt is counted on the address being signed in to and on the client address the ladder cannot answer; user.sign_in_failed is recorded with the reason internal_error, and the floor is waited out the account is inside its wait; the floor is waited 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 access token is signed, and a refresh token of 32 random bytes generated the refresh token is stored the event is recorded lastLoginAt is written the attempt is settled start idle Wait for a sign-in request idle · server step The realm resolver's onRequest hook, before any route code. /auth/login is an api route, so it answers on a realm's endpoint hosts too. A request refused here is never counted by the rate limiter. Find the realm of the host resolvingRealm · 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 body is read. Count the request against the login limit rateLimiting · server step loginSchema in apps/auth-server/src/app/schemas/auth.ts. Check the body against the login schema validatingBody · server step Two identifiers, both kept per realm and shared with POST /ui/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. 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 /ui/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. It used to answer 403 with the code EMAIL_NOT_VERIFIED, which let anyone tell a free address from a registered one (ANT-2026-SPEMY9GU). Apply the verified-account gate checkingVerified · server step findingUser Read the user, for its enabled flag findingUser · server step The system client is found, or created on the realm's first direct sign-in. The token's subject is the user, its client and its audience the system client (which registers no audience of its own), and it carries no scope and neither email nor email_verified (ANT-2026-2O0T72HY). It is signed with the JWT_PRIVATE_KEY key and its iss is JWT_ISSUER, in every realm, until #491 moves tokens onto the realm's keys. It lives the realm's access token lifetime (tokens.accessTokenLifespanSeconds, read off the realm row, #527), whatever the client's environment, and expires_in names it. Sign an access token for the system client issuingAccessToken · server step A new family with a random id, started now. Its expiry is the realm's refresh token lifetime (tokens.refreshTokenLifespanSeconds, #527) from now, capped at the end of the family when the realm sets sso_max_lifespan; no browser session caps it. Store the refresh token, hashed, as a new family storingRefreshToken · server step The event urn:qauth:event:user.signed_in:v1-rc.0, with the method password and the system client. Record the sign-in recordingSignIn · server step updatingLastLogin Record the time of the sign-in on the user updatingLastLogin · 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, 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 tokens_issued Answer with the tokens ends here Not counted, and no password is checked, even the right one. No event is recorded, unlike on POST /ui/login: only the metric login_attempts (result failure, reason locked_out) and the log line user.login.failure. The answer waits out the 500 ms floor that every refused credential waits. Distinct from the login limit, which the rate limiter answers. Refuse a locked-out address ends here Not counted, and no password is checked, even the right one. As for the lockout, no event is recorded: only the metric login_attempts (result failure, reason locked_out) and the log line user.login.failure. The answer waits out the 500 ms floor, and is the lockout's: it does not say which of the two refused the attempt. Refuse an attempt inside the account's wait ends here Fail closed: no credential is read and no password checked. user.sign_in_failed is recorded with the reason internal_error and the answer waits out the floor, as for any failure inside the handler; 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, and the log line says email_not_verified; the metric's label stays invalid_credentials, since /metrics is not authenticated. 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 Nothing is counted against the lockout. Refuse a body that does not match the login schema ends here misdirected_request.host Refuse a host QAuth does not answer for ends here too_many_requests.login_limit Refuse a request over the login limit ends here
POST /auth/login arrives with an email address and a password the host names no enabled realm with an issuer the request has its realm the address has used up its login requests the address is under its limit the body does not match the schema the body matches the schema an identifier is locked out; the floor is waited out the attempt is counted on the address being signed in to and on the client address the ladder cannot answer; user.sign_in_failed is recorded with the reason internal_error, and the floor is waited out the account is inside its wait; the floor is waited 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 access token is signed, and a refresh token of 32 random bytes generated fails: the database fails, or the hybrid signature cannot be stored the refresh token is stored fails: the database fails the event is recorded fails: the event store fails lastLoginAt is written fails: the database fails the attempt is settled start idle Wait for a sign-in request idle · server step The realm resolver's onRequest hook, before any route code. /auth/login is an api route, so it answers on a realm's endpoint hosts too. A request refused here is never counted by the rate limiter. Find the realm of the host resolvingRealm · 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 body is read. Count the request against the login limit rateLimiting · server step loginSchema in apps/auth-server/src/app/schemas/auth.ts. Check the body against the login schema validatingBody · server step Two identifiers, both kept per realm and shared with POST /ui/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. 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 /ui/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. It used to answer 403 with the code EMAIL_NOT_VERIFIED, which let anyone tell a free address from a registered one (ANT-2026-SPEMY9GU). Apply the verified-account gate checkingVerified · server step findingUser Read the user, for its enabled flag findingUser · server step The system client is found, or created on the realm's first direct sign-in. The token's subject is the user, its client and its audience the system client (which registers no audience of its own), and it carries no scope and neither email nor email_verified (ANT-2026-2O0T72HY). It is signed with the JWT_PRIVATE_KEY key and its iss is JWT_ISSUER, in every realm, until #491 moves tokens onto the realm's keys. It lives the realm's access token lifetime (tokens.accessTokenLifespanSeconds, read off the realm row, #527), whatever the client's environment, and expires_in names it. Sign an access token for the system client issuingAccessToken · server step A new family with a random id, started now. Its expiry is the realm's refresh token lifetime (tokens.refreshTokenLifespanSeconds, #527) from now, capped at the end of the family when the realm sets sso_max_lifespan; no browser session caps it. Store the refresh token, hashed, as a new family storingRefreshToken · server step The event urn:qauth:event:user.signed_in:v1-rc.0, with the method password and the system client. Record the sign-in recordingSignIn · server step updatingLastLogin Record the time of the sign-in on the user updatingLastLogin · 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, 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 tokens_issued Answer with the tokens ends here Not counted, and no password is checked, even the right one. No event is recorded, unlike on POST /ui/login: only the metric login_attempts (result failure, reason locked_out) and the log line user.login.failure. The answer waits out the 500 ms floor that every refused credential waits. Distinct from the login limit, which the rate limiter answers. Refuse a locked-out address ends here Not counted, and no password is checked, even the right one. As for the lockout, no event is recorded: only the metric login_attempts (result failure, reason locked_out) and the log line user.login.failure. The answer waits out the 500 ms floor, and is the lockout's: it does not say which of the two refused the attempt. Refuse an attempt inside the account's wait ends here Fail closed: no credential is read and no password checked. user.sign_in_failed is recorded with the reason internal_error and the answer waits out the floor, as for any failure inside the handler; 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, and the log line says email_not_verified; the metric's label stays invalid_credentials, since /metrics is not authenticated. 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 Nothing is counted against the lockout. Refuse a body that does not match the login schema ends here misdirected_request.host Refuse a host QAuth does not answer for ends here too_many_requests.login_limit Refuse a request over the login limit ends here user.sign_in_failed is recorded with the reason internal_error and the answer waits out the floor first. The steps before the failing one stay done: a refresh token stored before a later failure stays, though the caller never receives it. 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
Answer with the tokens
tokens_issued
200 · headers: {"Cache-Control":"no-store","Pragma":"no-cache"} · body: {"access_token":"an Ed25519 JWT signed with JWT_PRIVATE_KEY, iss JWT_ISSUER, for the system client","refresh_token":"64 hexadecimal characters","expires_in":"the access token's lifespan, in seconds","token_type":"Bearer"}
Refuse a locked-out address
too_many_requests.locked_out
429 · headers: {"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: {"type":"https://docs.qauth.dev/reference/problem-types/#too-many-requests","title":"Too many requests","status":429,"detail":"Too many failed login attempts. Please try again later.","instance":"/auth/login","code":"too_many_requests","trace_id":"the request id"}
Refuse an attempt inside the account's wait
too_many_requests.ladder_wait
429 · headers: {"Retry-After":"the seconds left on the account's wait, rounded up"} · body: {"type":"https://docs.qauth.dev/reference/problem-types/#too-many-requests","title":"Too many requests","status":429,"detail":"Too many failed login attempts. Please try again later.","instance":"/auth/login","code":"too_many_requests","trace_id":"the request id"}
Refuse the attempt when the failure ladder cannot answer
service_unavailable.ladder
503 · headers: {"Retry-After":"5"} · body: {"type":"https://docs.qauth.dev/reference/problem-types/#service-unavailable","title":"Service unavailable","status":503,"instance":"/auth/login","code":"service_unavailable","trace_id":"the request id"}
Refuse an address with no account, like a wrong password
invalid_credentials.unknown_identifier
401 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#invalid-credentials","title":"Invalid credentials","status":401,"detail":"Invalid email or password","instance":"/auth/login","code":"invalid_credentials","trace_id":"the request id"}
Refuse a wrong password
invalid_credentials.wrong_password
401 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#invalid-credentials","title":"Invalid credentials","status":401,"detail":"Invalid email or password","instance":"/auth/login","code":"invalid_credentials","trace_id":"the request id"}
Refuse an unconfirmed account under the gate, like a wrong password
invalid_credentials.email_not_verified
401 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#invalid-credentials","title":"Invalid credentials","status":401,"detail":"Invalid email or password","instance":"/auth/login","code":"invalid_credentials","trace_id":"the request id"}
Refuse a disabled user, like a wrong password
invalid_credentials.account_disabled
401 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#invalid-credentials","title":"Invalid credentials","status":401,"detail":"Invalid email or password","instance":"/auth/login","code":"invalid_credentials","trace_id":"the request id"}
Refuse a body that does not match the login schema
validation_error.body
400 · body: {"type":"https://docs.qauth.dev/reference/problem-types/#validation-error","title":"Validation error","status":400,"detail":"The request is not valid.","instance":"/auth/login","code":"validation_error","trace_id":"the request id","errors":[{"pointer":"/<the field>, such as /email","message":"the schema's message, such as: Invalid email format"}]}
Refuse a host QAuth does not answer for
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":"/auth/login","code":"misdirected_request","trace_id":"the request id"}
Refuse a request 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":"/auth/login","code":"too_many_requests","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":"/auth/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 QAuth does not answer for
2the request has its realmCount the request against the login limit

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1hostUnknownmisdirected_request.host20 of 140
2otherwiserateLimiting120 of 140
hostUnknown
gate == 'unknownHost'

Count the request against the login limit

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

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1rateLimitedtoo_many_requests.login_limit20 of 120
2otherwisevalidatingBody100 of 120
rateLimited
gate == 'rateLimited'

Check the body against the login schema

#IfThen
1the body does not match the schemaRefuse a body that does not match the login schema
2the body matches the schemaRefuse a locked-out address, else count the attempt

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1inputInvalidvalidation_error.body20 of 100
2otherwisecheckingLockout80 of 100
inputInvalid
gate == 'invalidInput'

Refuse a locked-out address, else count the attempt

#IfThen
1an identifier is locked out; the floor is waited outRefuse a locked-out address
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_out20 of 80
2otherwisereservingAttempt60 of 80
lockedOut
gate == 'lockedOut'

Reserve the attempt on the account's failure ladder

#IfThen
1the ladder cannot answer; user.sign_in_failed is recorded with the reason internal_error, and the floor is waited outRefuse the attempt when the failure ladder cannot answer
2the account is inside its wait; the floor is waited outRefuse an attempt inside the account's wait
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.ladder20 of 60
2ladderWaittoo_many_requests.ladder_wait20 of 60
3otherwisecheckingPassword20 of 60
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_verified2 of 8
2otherwisefindingUser6 of 8
unverifiedUnderTheGate
account in ['unverified', 'disabledUnverified'] and requireVerified

Settle the counted attempt

#IfThen
1the attempt is settledAnswer with the tokens

The first matching rule wins.

Exact conditions
#ConditionGoes toSituations
1otherwisetokens_issued3 of 3

Every way it can end

It ends withWhenWhat happens
Answer with the tokens
ends here
The host is a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. Check the password: the password is right
  8. Apply the verified-account gate: the address is confirmed, or the gate is off
  9. Read the user, for its enabled flag: the user is enabled
  10. Sign an access token for the system client: the access token is signed, and a refresh token of 32 random bytes generated
  11. Store the refresh token, hashed, as a new family: the refresh token is stored
  12. Record the sign-in: the event is recorded
  13. Record the time of the sign-in on the user: lastLoginAt is written
  14. Settle the counted attempt: the attempt is settled
Refuse a locked-out address
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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. Refuse a locked-out address, else count the attempt: an identifier is locked out; the floor is waited out
Refuse an attempt inside the account's wait
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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the account is inside its wait; the floor is waited 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the ladder cannot answer; user.sign_in_failed is recorded with the reason internal_error, and the floor is waited out
Refuse an address with no account, like a wrong password
ends here
The host is a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. 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 a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. 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 a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. Check the password: the password is right
  8. 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 a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. Check the password: the password is right
  8. Apply the verified-account gate: the address is confirmed, or the gate is off
  9. Read the user, for its enabled flag: the user is disabled; the floor is waited out
Refuse a body that does not match the login schema
ends here
The body does not pass the schema: an email that is not an address or is longer than 254 characters, a password longer than 256 characters, a missing field. An empty password passes the schema here, unlike on the form, and is counted and refused like a wrong one, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body does not match the schema
Refuse a host QAuth does not answer for
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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  2. Find the realm of the host: the host names no enabled realm with an issuer
Refuse a request over the login limit
ends here
The client address has used up its login requests for the window, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 store error
ends here
The host is a host of an enabled realm with an issuer, the address is under its limit, the body passes the schema, 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
  1. Wait for a sign-in request: POST /auth/login arrives with an email address and a password
  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 body against the login schema: the body matches the schema
  5. 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
  6. Reserve the attempt on the account's failure ladder: the attempt is counted on the account's ladder, and the next wait set
  7. 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 852 situations the model can reach, from 140 different starting situations.

Exact rules
IdNever
no-tokens-for-a-refused-account(refreshTokenStored or state == 'tokens_issued') 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
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', 'server_error.store'] and not floorWaited
unknown-address-verifies-the-decoystate == 'invalid_credentials.unknown_identifier' and not decoyVerified
tokens-issued-means-storedstate == 'tokens_issued' and not refreshTokenStored

Reference

All transitions (30)
FromEventGuardToEffectsTaken
idleLOGINresolvingRealmyes
resolvingRealmrule 1hostUnknownmisdirected_request.hostyes
resolvingRealmrule 2rateLimitingyes
rateLimitingrule 1rateLimitedtoo_many_requests.login_limityes
rateLimitingrule 2validatingBodyyes
validatingBodyrule 1inputInvalidvalidation_error.bodyyes
validatingBodyrule 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.storewaitFlooryes
checkingVerifiedrule 1unverifiedUnderTheGateinvalid_credentials.email_not_verifiedwaitFlooryes
checkingVerifiedrule 2findingUseryes
findingUserDONEuserDisabledinvalid_credentials.account_disabledwaitFlooryes
findingUserDONEissuingAccessTokenyes
findingUserFAIL.serverserver_error.storewaitFlooryes
issuingAccessTokenDONEstoringRefreshTokenyes
issuingAccessTokenFAIL.serverserver_error.storewaitFlooryes
storingRefreshTokenDONErecordingSignInstoreRefreshTokenyes
storingRefreshTokenFAIL.serverserver_error.storewaitFlooryes
recordingSignInDONEupdatingLastLoginyes
recordingSignInFAIL.serverserver_error.storewaitFlooryes
updatingLastLoginDONEclearingFailuresyes
updatingLastLoginFAIL.serverserver_error.storewaitFlooryes
clearingFailuresrule 1tokens_issuedyes