← → beats · Shift+← → chapters · Space play/pause · click any packet, entry, cookie or row to inspect it
What each part of the stage holds
- Browser · SPA: memory holds the access token (module memory only); localStorage holds the non-secret
auth_session_activehint; the cookie jar holdsrefresh_token(HttpOnly,Path=/api/v1/auth) andfastapi-csrf-token. The tabs switch between this user's devices. (tokenStorage.ts:16-38;auth_cookies.py:12-55;auth.py:1798-1825) - HTTP edge: what a request meets before its route body runs. The client address is read from forwarded headers only when the socket peer is a trusted proxy; the CSRF dependency compares
X-CSRF-Tokenwith the signedfastapi-csrf-tokencookie; login is rate-limited to5/minute. (client_address.py:139-166;main.py:217-221;deps.py:212-229;auth.py:79) - API · FastAPI: the auth routes and the
get_current_userdependency. The lit socket is what is running; the list under it is the checks it has passed, in order. (deps.py:66-130;auth.pyas cited per beat) - Redis: per user, the sorted sets
user:{id}:refresh_tokenanduser:{id}:access_token, each with a:metahash, drawn grouped bysession_id(letter and device). The meter counts live refresh entries against the cap. Outside testing, slowapi's rate-limit counters live here too. (token.py:26-48;rate_limit.py:69-79) - Postgres: the
Userrow (passwordhash,is_active,verified,is_locked,number_of_failed_attempts,needs_to_change_password) and theAuditLogrows thatlog_security_eventappends. (user_model.py:20-49;background_tasks.py:181-195,:202-235)
- refresh entry (square corners)
- access entry (round)
- request (filled, pointed)
- response (outlined)
- 2xx · 401 (expired: refresh) · 403 (refused: no refresh)
- expired, evicted, revoked or ended (✕, hatched)
Drive it yourself
A model of the rules on this page, not the server. It skips networking, timing and Redis failure. The cap is the code default, 5.
The sandbox needs scripts. The rules it follows are in the reference below (R3 and R4).
Reference
The same facts as the story, condensed for lookup. Facts read at commit 70b5a36; paths are relative to the repository root.
R1The allowlist at a glance
- Each user has two expiry-scored Redis sorted sets, one for refresh tokens and one for access tokens.
- Each set has a
:metahash: field = the token, value = JSON{issued_at, exp, session_id, origin_ip}. - Key names, verbatim:
user:{user_id}:refresh_token,user:{user_id}:refresh_token:meta,user:{user_id}:access_token,user:{user_id}:access_token:meta. - Tokens are grouped into sessions by
session_id = sha256(refresh_token).hexdigest(). - The allowlist holds exactly three token types under this key shape:
access_token,refresh_token,reset_token. Reset keys are not drawn. Email verification usesverification_token:{id}and is not part of the allowlist.
session_id for reading; in Redis each key is one flat set, and the grouping exists only in the session_id inside each :meta value. Shapes marked ⓘ have a note; hover, tap or focus them, or read the Diagram notes below. On a narrow screen the diagram scrolls sideways.- refresh entry (square corners)
- access entry (round corners)
- Session A (solid border)
- Session B (dashed border)
- Session C (dotted border)
Diagram notes
The text behind each ⓘ shape in the diagram.
user:{user_id}:refresh_token— sorted set. Member: the refresh JWT. Score: its absolute expiry,issued_at + expire_minutes*60. Expired members (score ≤ now) are purged with their meta fields on every add and on every read throughget_valid_tokens. After each add, the key's TTL is set to the latest member expiry. Adding here is the only thing that enforces the concurrent-session cap. (token.py:51-72,:219-226,:238-239)user:{user_id}:refresh_token:meta— hash. Field: the token. Value: JSON{issued_at, exp, session_id, origin_ip}, empty string when unset. A refresh entry getssession_id = sha256(refresh_token)by default when none is passed. (token.py:38-48,:221-222,:227-236)user:{user_id}:access_token— sorted set, scored by expiry.get_current_userchecks membership here on every authenticated request; a token missing from it gets 403 even though its JWT still decodes. (backend/app/api/deps.py:101-106)user:{user_id}:access_token:meta— hash, same JSON shape. Itssession_idis how logout, eviction and origin-anomaly revocation find an access token's session.origin_ipis always empty here. (token.py:136-151,:166-188,:264-280)- Session A: one
session_id—sha256(refresh_token).hexdigest(). The refresh entry and every access token minted from it carry the same value:add_session_tokens_to_redisat login,add_derived_access_token_to_redisat refresh. (token.py:21-23,:263-298) origin_ip— the full client address fromget_client_ipat login, stored on the refresh entry only. It is compared at network granularity, at refresh only, whenVALIDATE_TOKEN_IPis on. (token.py:126-133,:257-261)- Session C: form login —
POST /api/v1/auth/access-tokenissues an access token only, withsession_id = sha256(access_token). No refresh token, no cookie, no origin. The concurrent-session cap is not consulted, because only refresh adds enforce it. (auth.py:1122-1130,:1277-1287)
Source: backend/app/utils/token.py:14-18, :21-23, :26-31, :38-48, :219-222, :226-236, :257-261, :264-280, :283-298; backend/app/schemas/common_schema.py:18-22.
R2Refresh: every check, in order
POST /api/v1/auth/new_access_token requires CSRF and answers 201 on success. There is no rotation: the refresh token is unchanged (auth.py:938).
| # | Guard | Status | Security event | Source |
|---|---|---|---|---|
| 1 | Read the token from the cookie, else from the JSON body field refresh_token (for non-browser clients) | — | — | auth.py:943-945 |
| 2 | No token | 401 | refresh_token_missing | auth.py:946-959 |
| 3 | decode_token(..., token_type="refresh") fails | re-raised with its own status | from map_jwt_http_error_to_event | auth.py:962-973 |
| 4 | type != "refresh" | 400 | refresh_token_wrong_type | auth.py:974, :1082-1092 |
| 5 | Not in user:{user_id}:refresh_token | 403 “Refresh token invalid” | refresh_token_invalid | auth.py:976-987 |
| 6 | Origin anomaly. revoke_session ends this session only. The body is identical to row 5, on purpose. | 403 “Refresh token invalid” | refresh_origin_network_mismatch | auth.py:988-1014 |
| 7 | Bad UUID in sub | 403 | — | auth.py:1017-1033 |
| 8 | User not found or inactive | 404 | refresh_token_user_not_found_or_inactive | auth.py:1063-1081 |
| 9 | Success. One new access entry joins session sha256(refresh_token). Body: {access_token, token_type: "bearer"}. | 201 | refresh_token_success | auth.py:1034-1062; token.py:283-298 |
auth.py = backend/app/api/v1/endpoints/auth.py; token.py = backend/app/utils/token.py. Route and CSRF: auth.py:924-932.
R3Five ways a session ends
| Trigger | Scope | What is removed | HTTP answer | Source |
|---|---|---|---|---|
POST /auth/logout | One session: the caller's | The caller's refresh entry and every access entry carrying its session_id. The cookie is cleared. | Success; 400 “Unable to identify the current session.” when it cannot tell, and nothing is revoked | auth.py:1303-1342; token.py:166-188 |
| Eviction at the cap (a login) | One session per overflow: the oldest by issued_at | That session's refresh entry and its access entries | The new login succeeds. The evicted device's next request gets 403, not 401. | token.py:191-207, :136-151 |
| Origin mismatch (a refresh) | One session: the one refreshing | That session's refresh entry and its access entries | 403 “Refresh token invalid”, the same as an invalid token | auth.py:988-1014 |
POST /auth/change_password | All sessions, the caller's included | Every key of all three types: access, refresh and reset. Then one fresh session is issued, whose refresh token lasts REFRESH_TOKEN_EXPIRE_DAYS (default 7 days). | The new access token in the body; the new refresh cookie with a 7-day max-age | password_policy.py:261-262; auth.py:852-894 |
POST /auth/logout/all | All sessions | Every key of all three types and their :meta hashes. The cookie is cleared. | Success | auth.py:1368-1394; token.py:313-338 |
/logouttakes the session from the refresh cookie first. Without one, it uses the access token's metasession_id.- Both paths find access entries only through their meta
session_id. An access entry written without one is matched by neither, and stays allowlisted until its JWT expires. - A password change calls the same revoke-all as
/logout/all, in code, before the password is committed. It does not go through the route (auth.py:1380-1381).
password_policy.py = backend/app/utils/password_policy.py.
R4What the SPA does with each answer
- The access token is held in module memory only. A non-secret localStorage hint,
auth_session_active, says a cookie session may exist. - Every request is sent with credentials. Post, put, patch and delete also carry an
X-CSRF-Tokenheader. - Local logout clears the in-memory token, the hint and legacy storage keys. Only the server clears the HttpOnly cookie.
| Answer | From | SPA reaction | Source |
|---|---|---|---|
| 401 expired | decode_token: the JWT's exp passed | Not the refresh endpoint and not yet retried: with the hint set, dispatch refreshAccessToken, store the new token and retry once. Without the hint, local logout. A failed refresh is a local logout. | api.ts:210-249; authSlice.ts:49-59 |
| 403 temporary password | {"code": "password_change_required", "message": "Password change required."} from get_current_user, after the active check and before the superuser return, unless the route opted in. Only change password, logout, logout all and GET /users/me opt in. | Checked before the generic 403 branch: dispatch markPasswordChangeRequired() and reject. No logout and no CSRF retry. The message avoids the words “token” and “CSRF” on purpose, because the 403 handler matches them (the heuristic tracked in #322). | deps.py:60-63, :121-125, :57-59; auth.py:810, :1308, :1373; backend/app/api/v1/endpoints/user.py:100; api.ts:56-66, :253-256 |
| 403 allowlist | “Could not validate credentials, token invalid or revoked.” | No refresh. The message contains “token invalid”, so the CSRF branch refetches the CSRF token and retries once. The retry gets the same 403, which contains “token”, so the SPA logs out locally. Derived from reading the code, not from a run #322 | deps.py:101-106; api.ts:258-296 |
| 403 refresh | “Refresh token invalid” from /auth/new_access_token (allowlist miss or origin mismatch) | The refresh call goes through the shared interceptor (auth.service.ts:29-35), whose refresh exclusion guards only the 401 branch (api.ts:214-221). The message contains “token invalid”, so the SPA refetches the CSRF token and replays the refresh once. The replayed 403 contains “token” and dispatches logout, and the thunk's catch dispatches logout too. Derived from reading the code, not from a run #322 | auth.service.ts:29-35; api.ts:214-221, :258-296; authSlice.ts:49-59; auth.py:976-1014 |
| 403 CSRF | “CSRF token validation failed: …” | Not yet retried: clear the CSRF token, refetch it, retry once. | deps.py:212-229; api.ts:258-283 |
api.ts = react-frontend/src/services/api.ts; authSlice.ts = react-frontend/src/store/slices/authSlice.ts; auth.service.ts = react-frontend/src/services/auth.service.ts; deps.py = backend/app/api/deps.py. Storage: react-frontend/src/lib/tokenStorage.ts:16, :18-38, :50-67, :99-103. Client: api.ts:76, :83-89, :92-113. Logout thunks: authSlice.ts:186-211.
R5The origin-network rule
- The check runs at refresh only. Access tokens are never checked against the origin (
token.py:126-128). - It is gated by
VALIDATE_TOKEN_IP, defaulttrue(token.py:130-133;config.py:330). - The origin is the full client address, stored on the refresh entry only, and compared by network.
- IPv4 addresses are compared as
/24networks, IPv6 as/64(origin_network.py:21-22). - IPv4-mapped IPv6 is normalised to IPv4 first (
origin_network.py:43-44). - A missing or unparseable address on either side is not a mismatch (
origin_network.py:63-80). - IPv4 against IPv6 is a mismatch (
origin_network.py:70-75,:81). - A mismatch revokes that one session and answers like an invalid token (R2, row 6).
- The compared address has one reader,
get_client_ip, which returnsrequest.client.host(client_address.py:158-166). ProxyHeadersMiddlewarecorrects that value once. It is registered last, so it is the outermost middleware (backend/app/main.py:217-221).- Forwarded headers count only when the socket peer is in
TRUSTED_PROXIES. ThenX-Forwarded-Foris read right to left past trusted proxies, thenX-Real-IP, else the peer (client_address.py:139-155). - Wildcard and
/0entries inTRUSTED_PROXIESare refused at settings load (client_address.py:86-100).
origin_network.py = backend/app/utils/origin_network.py; client_address.py = backend/app/utils/client_address.py; config.py = backend/app/core/config.py.
R6The refresh cookie
| Attribute | Value | Source |
|---|---|---|
| Name | settings.REFRESH_TOKEN_COOKIE_NAME, default "refresh_token" | config.py:111 |
| HttpOnly | always | auth_cookies.py:34 |
| Path | /api/v1/auth ({API_V1_STR}/auth) | auth_cookies.py:12-14; config.py:64 |
| Secure | REFRESH_COOKIE_SECURE if set, else true only when MODE == production | auth_cookies.py:17-21 |
| SameSite | REFRESH_COOKIE_SAMESITE, default lax; invalid values fall back to lax | auth_cookies.py:24-28; config.py:115 |
| Max-Age | By default REFRESH_TOKEN_EXPIRE_MINUTES * 60: a /login session's cookie. POST /auth/change_password passes its own 7-day max-age. | auth_cookies.py:53-54; auth.py:890-894 |
Clearing the cookie uses the same attributes (auth_cookies.py:58-60).
Two different things called refresh_token
Comes from the setting
REFRESH_TOKEN_COOKIE_NAME. It is what the browser stores and sends to /api/v1/auth.user:{user_id}:refresh_token
The
token_type segment: the TokenType.REFRESH enum value. It names a sorted set, not a cookie.They are spelled the same by default and set in different places. Renaming the cookie does not rename the key, and the reverse.
auth_cookies.py = backend/app/utils/auth_cookies.py.
R7Settings
| Setting | Default | Scope | Source |
|---|---|---|---|
ACCESS_TOKEN_EXPIRE_MINUTES | 60 minutes by default; deployments set their own | Every access token | config.py:93 |
REFRESH_TOKEN_EXPIRE_MINUTES | 144000 minutes (100 days) | Refresh tokens issued by /login, and the cookie's default max-age | config.py:94; auth.py:316, :330 |
REFRESH_TOKEN_EXPIRE_DAYS | 7 days | Only the session that POST /auth/change_password issues, and its cookie. backend/.env.production.example:43 sets 100; backend/.env.example:77, backend/.env.test.example:40 and both hub-runtime env.example files set 7. | config.py:95; auth.py:864, :887 |
CONCURRENT_SESSION_LIMIT | 5 | Live refresh tokens per user. 0 or less disables the cap. | config.py:247; token.py:193 |
VALIDATE_TOKEN_IP | true | The origin check at refresh | config.py:330 |
REFRESH_TOKEN_COOKIE_NAME | "refresh_token" | The cookie's name only, not the Redis key | config.py:111 |
REFRESH_COOKIE_SAMESITE | "lax" | The cookie | config.py:115 |
R8Form login
POST /api/v1/auth/access-token(OAuth2 form) issues a lone access token, withsession_id = sha256(access_token).- It sets no refresh token, no cookie and no origin.
- The cap is not consulted, because only refresh adds enforce it.
- Tracked in #279.
Source: auth.py:1122-1130, :1277-1287.