Auth explainer · for engineers changing or reviewing auth, backend and SPA

Session lifecycle, run on the live system

When a token is issued, used, refreshed or revoked: which Redis allowlist entries change, which other sessions survive, and what does the SPA do with the response?

    Static overview. Left to right: the browser SPA (memory holds the access token, localStorage holds the auth_session_active hint, the cookie jar holds refresh_token, HttpOnly with Path=/api/v1/auth, and fastapi-csrf-token); the HTTP edge (client address from trusted proxies only, the CSRF header check, the 5/minute login rate limit); the FastAPI service with its auth routes and the get_current_user dependency; Redis, holding user:{id}:refresh_token and user:{id}:access_token sorted sets with :meta hashes, grouped by session_id; and Postgres, holding the User row and AuditLog. Requests travel left to right on the upper wire and responses return on the lower wire.BROWSER · SPAMemory: the access token, e.g. A1localStorage: auth_session_activeCookie jar: refresh_token (HttpOnly,Path=/api/v1/auth) and fastapi-csrf-tokenHTTP EDGEclient addressfrom trustedproxies only;X-CSRF-Token vssigned cookie;login 5/minuteAPI · FASTAPI/csrf-token/loginget_current_user/new_access_token/logout/logout/all/change_passwordget_current_user:decode JWT → allowlist→ user active →temporary passwordREDISuser:{id}:refresh_token + :metauser:{id}:access_token + :metascore = expiry; meta {issued_at, exp,session_id, origin_ip}session_id = sha256(refresh_token)POSTGRESUser row: password hash, is_active, verified,is_locked, number_of_failed_attempts,needs_to_change_password · AuditLog rowsrequests →← responses
    ready

    ← → 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_active hint; the cookie jar holds refresh_token (HttpOnly, Path=/api/v1/auth) and fastapi-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-Token with the signed fastapi-csrf-token cookie; login is rate-limited to 5/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_user dependency. The lit socket is what is running; the list under it is the checks it has passed, in order. (deps.py:66-130; auth.py as cited per beat)
    • Redis: per user, the sorted sets user:{id}:refresh_token and user:{id}:access_token, each with a :meta hash, drawn grouped by session_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 User row (password hash, is_active, verified, is_locked, number_of_failed_attempts, needs_to_change_password) and the AuditLog rows that log_security_event appends. (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).

    Device
    Refresh from

    Reference

    The same facts as the story, condensed for lookup. Facts read at commit 70b5a36; paths are relative to the repository root.

    R1

    The 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 :meta hash: 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 uses verification_token:{id} and is not part of the allowlist.
    Four columns: the refresh sorted set user:{user_id}:refresh_token, its meta hash, the access sorted set user:{user_id}:access_token, and its meta hash. Three rows, one per session. Session A: refresh token RT_A, and access tokens AT_A1 from login and AT_A2 from a refresh, all carrying session_id sha256(RT_A). Only the refresh meta entries hold an origin_ip; every access meta stores it empty. Session B: refresh token RT_B and access token AT_B1, session_id sha256(RT_B). Session C, from form login: no refresh entry, a lone access token AT_C1 with session_id sha256(AT_C1) and no origin. Refresh tokens (token_type refresh_token) Access tokens (token_type access_token) user:{user_id}:refresh_token sorted set · score = expiry ⓘ user:{user_id}:refresh_token:meta hash · field = token, value = JSON ⓘ user:{user_id}:access_token sorted set · score = expiry ⓘ user:{user_id}:access_token:meta hash · field = token, value = JSON ⓘ Session A login, then a refresh ⓘ shared id RT_A score: RT_A's expiry RT_A → { issued_at, exp, session_id: sha256(RT_A), origin_ip: "‹login address›" } AT_A1 minted at login AT_A2 minted by a refresh AT_A1 → {issued_at, exp, session_id: sha256(RT_A), origin_ip: "" } AT_A2 → {issued_at, exp, session_id: sha256(RT_A), origin_ip: "" } Session B login on another device RT_B score: RT_B's expiry RT_B → { issued_at, exp, session_id: sha256(RT_B), origin_ip: "‹login address›" } AT_B1 minted at login AT_B1 → {issued_at, exp, session_id: sha256(RT_B), origin_ip: "" } Session C form login, access only ⓘ side note no refresh entry · no cookie · no origin_ip a session can be a lone access token AT_C1 minted by form login AT_C1 → {issued_at, exp, session_id: sha256(AT_C1), origin_ip: "" }
    Illustrative state, separate from the story's devices. Rows are drawn grouped by 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 through get_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 gets session_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_user checks 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. Its session_id is how logout, eviction and origin-anomaly revocation find an access token's session. origin_ip is 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_redis at login, add_derived_access_token_to_redis at refresh. (token.py:21-23, :263-298)
    • origin_ip — the full client address from get_client_ip at login, stored on the refresh entry only. It is compared at network granularity, at refresh only, when VALIDATE_TOKEN_IP is on. (token.py:126-133, :257-261)
    • Session C: form login — POST /api/v1/auth/access-token issues an access token only, with session_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.

    R2

    Refresh: 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).

    #GuardStatusSecurity eventSource
    1Read the token from the cookie, else from the JSON body field refresh_token (for non-browser clients)——auth.py:943-945
    2No token401refresh_token_missingauth.py:946-959
    3decode_token(..., token_type="refresh") failsre-raised with its own statusfrom map_jwt_http_error_to_eventauth.py:962-973
    4type != "refresh"400refresh_token_wrong_typeauth.py:974, :1082-1092
    5Not in user:{user_id}:refresh_token403 “Refresh token invalid”refresh_token_invalidauth.py:976-987
    6Origin anomaly. revoke_session ends this session only. The body is identical to row 5, on purpose.403 “Refresh token invalid”refresh_origin_network_mismatchauth.py:988-1014
    7Bad UUID in sub403—auth.py:1017-1033
    8User not found or inactive404refresh_token_user_not_found_or_inactiveauth.py:1063-1081
    9Success. One new access entry joins session sha256(refresh_token). Body: {access_token, token_type: "bearer"}.201refresh_token_successauth.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.

    R3

    Five ways a session ends

    TriggerScopeWhat is removedHTTP answerSource
    POST /auth/logoutOne session: the caller'sThe 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 revokedauth.py:1303-1342; token.py:166-188
    Eviction at the cap (a login)One session per overflow: the oldest by issued_atThat session's refresh entry and its access entriesThe 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 refreshingThat session's refresh entry and its access entries403 “Refresh token invalid”, the same as an invalid tokenauth.py:988-1014
    POST /auth/change_passwordAll sessions, the caller's includedEvery 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-agepassword_policy.py:261-262; auth.py:852-894
    POST /auth/logout/allAll sessionsEvery key of all three types and their :meta hashes. The cookie is cleared.Successauth.py:1368-1394; token.py:313-338
    • /logout takes the session from the refresh cookie first. Without one, it uses the access token's meta session_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.

    R4

    What 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-Token header.
    • Local logout clears the in-memory token, the hint and legacy storage keys. Only the server clears the HttpOnly cookie.
    AnswerFromSPA reactionSource
    401 expireddecode_token: the JWT's exp passedNot 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 #322deps.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 #322auth.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.

    R5

    The 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, default true (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 /24 networks, 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 returns request.client.host (client_address.py:158-166).
    • ProxyHeadersMiddleware corrects 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. Then X-Forwarded-For is read right to left past trusted proxies, then X-Real-IP, else the peer (client_address.py:139-155).
    • Wildcard and /0 entries in TRUSTED_PROXIES are 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.

    R6

    The refresh cookie

    AttributeValueSource
    Namesettings.REFRESH_TOKEN_COOKIE_NAME, default "refresh_token"config.py:111
    HttpOnlyalwaysauth_cookies.py:34
    Path/api/v1/auth ({API_V1_STR}/auth)auth_cookies.py:12-14; config.py:64
    SecureREFRESH_COOKIE_SECURE if set, else true only when MODE == productionauth_cookies.py:17-21
    SameSiteREFRESH_COOKIE_SAMESITE, default lax; invalid values fall back to laxauth_cookies.py:24-28; config.py:115
    Max-AgeBy 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

    The cookie name
    refresh_token
    Comes from the setting REFRESH_TOKEN_COOKIE_NAME. It is what the browser stores and sends to /api/v1/auth.
    The Redis key segment
    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.

    R7

    Settings

    SettingDefaultScopeSource
    ACCESS_TOKEN_EXPIRE_MINUTES60 minutes by default; deployments set their ownEvery access tokenconfig.py:93
    REFRESH_TOKEN_EXPIRE_MINUTES144000 minutes (100 days)Refresh tokens issued by /login, and the cookie's default max-ageconfig.py:94; auth.py:316, :330
    REFRESH_TOKEN_EXPIRE_DAYS7 daysOnly 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_LIMIT5Live refresh tokens per user. 0 or less disables the cap.config.py:247; token.py:193
    VALIDATE_TOKEN_IPtrueThe origin check at refreshconfig.py:330
    REFRESH_TOKEN_COOKIE_NAME"refresh_token"The cookie's name only, not the Redis keyconfig.py:111
    REFRESH_COOKIE_SAMESITE"lax"The cookieconfig.py:115
    • The refresh lifetime depends on the route that issued the session. This split is tracked in #274.
    • The env examples disagree with each other on these lifetimes. That is tracked in #323.
    R8

    Form login

    • POST /api/v1/auth/access-token (OAuth2 form) issues a lone access token, with session_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.