Module kit / Identity and auth
Session login, refresh and logout
identity-auth/session-login-refresh-logout
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi, ed25519-jws, cryptography, sqlalchemy-sync, postgres, redis |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/error-envelope, _shared/test-layout, _shared/alembic-migrations |
| Frontends | identity-auth/ui/auth-screens, identity-auth/ui/profile-and-passkeys |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Version | 1 |
Purpose
A password proves who is asking, a grant row decides whether they may enter, and what they walk away with is a pair of Ed25519 tokens bound to one surface plus a sessions row that can be switched off: POST /api/auth/login mints, POST /api/auth/refresh re-mints the same surface after re-reading the grant, POST /api/auth/logout deactivates the row, and GET /api/auth/me tells the client which surfaces it holds. The invariant that makes it trustworthy is that every mint goes through create_session(..., surface=), which asserts the surface is issuable, reads user_surface_grants live and refuses before a single byte is signed; a refresh inherits the presented token's aud and asks the same question again, so a revoked grant ends the session at its next refresh and a token can never be laundered from one surface to another.
This recipe covers the seven routes on auth_router that issue, renew, revoke and describe a password session, the cookies they set, the sessions row, and the claim set as it is built. The primitive that signs is B3; the surface question is B4; the dependency that reads the token on every other route is S5.
When to use
Include it from any service that mints a session for a human, and read it before touching a login route: "why does login return 403 for a correct password", "the refresh keeps failing after we changed the role", "logout did not sign me out", "which cookie goes where", "how many devices can be signed in", "what is in the token". Trigger phrases: "create_session", "refresh_session", "revoke_session", "sessions table", "MAX_SESSIONS_PER_USER", "access_token cookie", "verification_required at login".
Do not use it for the sign-in ceremonies that end in create_session from somewhere else (B5 magic link, B7 passkeys, B8 OIDC, B20 desk domain sign-in: each resolves its own surface and then calls the same function), for the change-of-password flows other than /change-password (B6), for API keys (B10), or for the gateway's own session on Prima (B11, B13). For the client-side half, the single-flight refresh and the storage mode, use S2.
Stack assumptions
Measured at fd8558f (2026-09-12): fastapi==0.118.2, cryptography==46.0.0 (the only signer), bcrypt==5.0.0, sqlalchemy==2.0.43 sync, python-jose==3.5.0 for the JWTError type only (services/auth_service.py 14 to 18), redis for the optional jti denylist through a lazily built synchronous client (76 to 107). Foundations assumed: S4 (the router is mounted ALL_SURFACES, app_fastapi.py 820), S5 (CurrentUser, ActiveUser, the credential order Bearer then cookie then key), S7 (string detail), S9.
Environment names and fail behaviour (core/config.py):
| Name | Default | Behaviour |
|---|---|---|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES | 60 (117) | access lifetime and the expires_in in every body; 15 when the denylist is on (auth_service.py 54, 428 to 432) |
JWT_REFRESH_TOKEN_EXPIRE_DAYS | 30 (118) | refresh lifetime and sessions.expires_at |
JWT_DENYLIST_ENABLED | off (read through getattr, else the env var, 57 to 73) | on: revoke_jti on logout, is_jti_revoked at decode; Redis down means expiry only (114 to 164) |
REDIS_ENABLED, REDIS_URL | True; REDIS_URL declared None (388 to 389) but never left empty | construct_redis_url (a model_validator, 417 to 434) fills REDIS_URL from REDIS_HOST:REDIS_PORT/REDIS_DB (redis://localhost:6379/0 by default, with REDIS_PASSWORD folded in) whenever it is empty, so the guard at auth_service.py 93 builds the denylist client whenever REDIS_ENABLED is true |
MAX_SESSIONS_PER_USER | 5 (890) | active rows per (user_id, acting_email); the oldest beyond the cap are deactivated at mint |
ENVIRONMENT | development | cookies carry Secure only in production (auth_router.py 61 to 78) |
REQUIRE_EMAIL_VERIFICATION, ENABLE_EMAIL_VERIFICATION | True, True | both on: login and refresh refuse an unverified account (B1) |
JWT_SIGNING_KEY_SOURCE, JWT_ED25519_PRIVATE_KEY, JWT_ISSUER | B3 | a misconfigured key is a boot failure, never a login failure |
JWT_LEGACY_NO_AUD_UNTIL | empty | a refresh token with no aud is refused outside the window (resolve_token_surface, 524 to 544) |
Data model
-- models/user_model.py 157 to 184. The DDL is spread over the chain: CREATE TABLE in
-- baseline_prod_schema_001 1784 to 1795, the two UNIQUE constraints at 3006 to 3015, the
-- three indexes at 3483 to 3493, the FK with ON DELETE CASCADE in
-- deferred_fk_phantom_tables_001 727 to 730, acting_email in 20260905_0001 (73)
sessions (
id uuid PRIMARY KEY,
user_id uuid NOT NULL, -- fk_sessions_user_id_users ... ON DELETE CASCADE (deferred_fk 727 to 730)
access_token text NOT NULL, -- the compact JWS, verbatim (Gotchas); uq_sessions_access_token
refresh_token text, -- the compact JWS, verbatim; uq_sessions_refresh_token
ip_address varchar(50), user_agent text,
is_active boolean, -- no DDL default; the ORM sets True at insert (user_model.py 167)
expires_at timestamp NOT NULL, -- now() + JWT_REFRESH_TOKEN_EXPIRE_DAYS at mint; never moved by refresh
created_at timestamp, last_activity timestamp,
acting_email varchar(320) NULL -- the mailbox that signed in when it is not users.email; NULL, never ''
);
CREATE INDEX idx_session_token ON sessions(access_token);
CREATE INDEX idx_session_user_active ON sessions(user_id, is_active);
CREATE INDEX idx_session_expires ON sessions(expires_at);
-- read here, owned elsewhere: users.password_hash, is_active, is_verified, last_login (B1);
-- user_surface_grants (B4); workspaces and workspace_users for tids_n and tid (B15)
The claim set, as _mint builds it (auth_service.py 366 to 404) on top of create_session's {sub, aem?, dsk?} (729 to 749): iss = settings.JWT_ISSUER, aud = <surface> (a string), ver = 2, type = access | refresh, jti = uuid4(), iat and exp as integer seconds, then tids_n and, only when it is exactly 1, tid (_tenant_claims, 230 to 275: SELECT DISTINCT w.id FROM workspaces w LEFT JOIN workspace_users wu ... WHERE w.owner_id = :uid OR wu.id IS NOT NULL). Header {"alg": "EdDSA", "typ": "JWT", "kid": <thumbprint>} (B3). A tenant lookup that raises fails the mint rather than minting tids_n: 0 (299 to 304).
The bodies, from schemas/auth_schemas.py: UserLogin (31 to 34) is email, password, and a remember_me that nothing reads; TokenResponse (36 to 40) is access_token, refresh_token, token_type = "bearer", expires_in; TokenRefresh (42 to 43) is refresh_token; UserResponse (45 to 73) is id, email, username, first_name, last_name, company, role (a label, never a permission, comment 62 to 65), is_active, is_verified, is_admin, created_at, client_status, then surfaces: List[str] = [] (deny by default) and acting_email; it also carries subscription_tier, token_quota, tokens_used_current_period and voucher_tokens, which are billing-credits' columns read through this body and not this recipe's contract (a rebuilt service without that module omits them); UserUpdate (75 to 78) is first_name, last_name, company; PasswordChange (80 to 97) is current_password and a new_password that must differ and carry one uppercase, one lowercase and one digit.
The optional denylist, when JWT_DENYLIST_ENABLED is on (auth_service.py 49, 108 to 164): one Redis string per revoked token at key jwt:denylist:<jti> with value "1" and TTL = exp - now() in whole seconds (the configured refresh window when exp is unknown); a token already past exp is never written (revoke_jti 135 to 137); the lookup is EXISTS on the same key and answers False when the flag is off or Redis fails (is_jti_revoked 147 to 164).
What is not stored: a hash of either token (the raw JWS is the row key, Gotchas); the jti anywhere but Redis when the denylist is on; the surface on the row (it is the token's aud); a device name (only user_agent).
API surface
All on auth_router, prefix /api/auth, mounted ALL_SURFACES (app_fastapi.py 820). /login declares auth_rate_limit (30 per minute per IP, B18); the others declare no limiter.
| Route | Auth dep | Tenancy | Codes | Error detail strings |
|---|---|---|---|---|
POST /api/auth/login | none | none (mints aud=labs only; the workspace enters the token as tid) | 200 {access_token, refresh_token, token_type, expires_in, user{id, email, name, username, emailVerified, is_admin, surfaces, client_status}} plus two cookies; 200 {status: "verification_required", ...} without tokens; 400; 401; 403; 429; 503 | 401 "Invalid email or password" (462 to 466); 403 "Account is disabled" (468 to 472); 400 "Please complete your account setup by setting a password first" (474 to 479, unreachable, Gotchas); 403 "This account is not permitted to sign in to Alchemy Labs." (_require_labs_grant, 140 to 152); 503 "Access control is temporarily unavailable." (606 to 610) |
POST /api/auth/refresh | none (the body is the credential) | inherits the token's aud | 200 TokenResponse plus the two cookies; 401 | 401 with str(ValueError): "Invalid refresh token: <reason>", "Invalid refresh token: no access grant for surface '<s>'", "Invalid refresh token: email not verified", "Session not found or expired" (665 to 669) |
POST /api/auth/logout | CurrentUser | none | 204, cookies deleted; 401 from the dependency | none of its own |
GET /api/auth/me | ActiveUser | none | 200 UserResponse with surfaces sorted from the grant table and acting_email from request.state or the account's email (697 to 715); 401; 403 | S5's strings |
PATCH /api/auth/me | ActiveUser | none | 200 UserResponse (without surfaces, Gotchas); 422 | none |
POST /api/auth/change-password | ActiveUser | none | 204; 400; 422 | 400 "Current password is incorrect" (855 to 862) |
GET /api/auth/workspaces | ActiveUser | the caller's own memberships | 200 {workspaces: [{id, name, slug, role, is_owner}]} (733 to 820) | none; five fields and no sixth, owner-first then name |
Cookies on every 200 that carries tokens (_set_auth_cookies, 59 to 79): access_token, HttpOnly, SameSite=Lax, Path=/api, Max-Age = JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60; refresh_token, the same on Path=/api/auth with Max-Age = JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400; Secure in production; no Domain. Logout deletes both by name and path (82 to 85). The surface-aware writer in core/session_cookies.py (64 to 113: the surface's cookie_domain, SameSite=None; Secure when the response host is not the surface's) is used by the passkey login only (api/routers/webauthn_router.py 49, 145); the password, OIDC, magic-link and refresh paths all use the router's own helper (five call sites in auth_router.py at fd8558f: 409, 622, 656, 1240, 1711; one in auth_infomaniak_router.py 130).
Application logic
login(credentials, request, response, background_tasks, db) (auth_router.py 436 to 640), in order:
auth_service.authenticate_user(email, password)(auth_service.py662 to 681): oneSELECTby lower-cased email in its own session;verify_passwordisbcrypt.checkpwon the first 72 bytes andFalsefor aNULLhash; on successusers.last_login = now()is committed before anything else is checked.Noneis 401.not user.is_activeis 403"Account is disabled"._require_labs_grant(user)(140 to 152):LABS not in get_user_surfaces_standalone(user.id)is 403 with a WARNING. This is prerequisite bugfix 3 of ADR-0020 section 1 (0020-prima-identity-and-prepaid-credits.md151 at 37c6543:POST /api/auth/login"denies any user without alabsgrant"); the docstring at 143 to 145 paraphrases it as asking the Labs question rather than "does a row exist in users".- The verification gate (485 to 502):
REQUIRE and ENABLE and not is_verifiedis logged, a verification mail is re-queued best-effort (_maybe_resend_verification, 117 to 137) and the 200verification_requiredbody is returned with no tokens and no cookies. - Workspace safety net (599 to 610):
desk_access.is_desk_only_user(db, user.id)decides whether_ensure_workspace(user)(176 to 232) may create a personal workspace for an account that has none;DeskAccessUnavailableis 503. auth_service.create_session(user.id, request.client.host, User-Agent, surface=LABS)(683 to 769):assert_issuable; in its own session,get_user_surfacesandSurfaceAccessDeniedwhenlabsis missing (711 to 717, unreachable after step 3 for this route and load-bearing for every other caller);_cleanup_user_sessions(db, user_id, acting_email=None)(1307 to 1332: the active rows for the user, newest first; when their number is at leastMAX_SESSIONS_PER_USERevery row from indexMAX - 1on is deactivated, so the new row is at most the fifth); claims{sub}plusaemwhen an acting email was given anddsk: 1whenis_desk_only_user(aDeskAccessUnavailablepropagates rather than minting a token without the fence, 733 to 749);create_access_tokenandcreate_refresh_tokenwith the same surface and the samedb; theSessionrow withexpires_at = now() + 30 days; one commit._set_auth_cookies(response, access, refresh)and the body withuser.surfacesread live once more (612 to 640).
Without B20 (no service desk) step 5's desk-only question, its 503, the dsk claim in step 6 and the same question inside refresh_session (838 to 842) are omitted, and _ensure_workspace runs unconditionally; aem stays, since it belongs to the row, not to the desk.
refresh_token(token_data, response) (642 to 669) calls auth_service.refresh_session(refresh_token) (771 to 854): decode_token_with_surface(token, token_type="refresh") (signature, exp, iss, type == "refresh", denylist, then aud resolved, 493 to 556; any JWTError, KeyError or ValueError becomes ValueError("Invalid refresh token: ...")); in a fresh session, the grant is re-read and a missing one is a ValueError (786 to 792); the verification gate (794 to 806); the row is found by refresh_token == <presented> and is_active and expires_at > now() (808 to 821); aem is taken from the row and dsk from the memberships, never from the presented token (823 to 842); a new pair is minted with the same surface; the row's two token columns and last_activity are updated, expires_at is not (848 to 852). The route turns any ValueError into 401 and otherwise sets the cookies and returns TokenResponse.
logout(current_user, request, response) (671 to 695): the access token is read from Authorization: Bearer else the access_token cookie (the same order as S5); auth_service.revoke_session(token) (856 to 875) denylists the access jti when the flag is on, finds the row by access_token, sets is_active = False, denylists the row's refresh jti, commits; the cookies are deleted; 204. The {refresh_token} body that frontend/src/services/api.ts logout() would send (142) is not read, and at fd8558f that method has no caller in frontend/src at all, tests included (grep -rn '\.logout(' src prints nothing; Frontend, Gotchas).
get_current_user at GET /me (697 to 715): UserResponse.model_validate(user), then surfaces from get_user_surfaces_standalone and acting_email from request.state.acting_email (set by S5 from the aem claim) or the account's own email. update_profile (823 to 843) writes the three optional fields and commits. change_password (845 to 877): verify_password against the stored hash, else 400; a new hash; commit; UPDATE sessions SET is_active = false WHERE user_id = :u; commit; 204 (no denylist call, Gotchas).
list_my_workspaces (733 to 820) is the seventh route and belongs to B15's memberships; it is here because it is mounted on auth_router and because a picker reads it straight after /me. One SELECT DISTINCT over workspaces left-joined to workspace_users (w.owner_id = :uid OR wu.id IS NOT NULL, w.is_active), the same predicate that authorises the mint (_resolve_tenant_for_jwt_user and WorkspaceMiddleware._validate_workspace_access, named in the docstring at 770 to 775), ordered is_owner DESC, name ASC on the output-column aliases; read-only, no user_id parameter, five fields per row (id, name, slug, role, is_owner) and no sixth.
Invariants
- Every token pair is minted by
create_sessionorrefresh_session, both of which readuser_surface_grantsin the same database session that writes thesessionsrow; no route in this recipe signs a token any other way (grep -n "create_access_token\|create_refresh_token" api/routers/auth_router.pyat fd8558f prints nothing; both calls live inauth_service.py752 to 753 and 845 to 846). - A refresh answers with the presented token's surface and never another (
test_refresh_cannot_launder_a_prima_session_into_a_labs_one,test_surface_scoped_access.py568). - A refresh token presented as a bearer credential is refused because
type != "access"(_decode_unverified_audience514 to 515). The only test of it at fd8558f is the Prima-side one:test_a_refresh_token_is_refused_as_a_bearer_credential(interop 458 to 480) mints a Labs refresh token and asserts Prima'sparse_claimsraisestoken_type_invalid; no test callsdecode_token(..., token_type="access")on a refresh token (grep -rn "Invalid token type" tests/prints nothing). - A refresh re-derives
aemfrom the row anddskfrom the memberships (823 to 842), so a corrected acting email or a changed role lands at the next refresh, at mostJWT_ACCESS_TOKEN_EXPIRE_MINUTESaway. - A refresh requires an active, unexpired
sessionsrow whoserefresh_tokenequals the presented token, so logout (row inactive) andchange-password(all rows inactive) stop renewal even with the denylist off. - A password login mints
aud = "labs"and nothing else; a Prima surface never gets a password session from this route (thesurface=LABSliteral at 618). expires_inin the body equals the access token'sexp - iatonly when the denylist is off (Gotchas).- At most
MAX_SESSIONS_PER_USERactive rows per(user_id, acting_email), enforced at mint, never by a job. users.last_loginmoves on every successful password check, including the ones that then answer 403 orverification_required.GET /mereads the grant table on every call;surfacesin its body is never taken from the token.
Frontend
Measured at fd8558f (2026-09-12), what the SPA does with these routes:
identity-auth/ui/auth-screens LoginAlchemy.tsx: the password form (quiet, below magic link and passkey);
handlePasswordSubmit (172 to 189) awaits AuthContext.login and renders
error.message, which AuthContext (211 to 218) sets from the envelope's
error string, so 401, 403 and 503 all render as one line of copy; there is
no 429 branch and no verification_required branch, and the refusal is not
detected at all: AuthContext.login (182 to 210) destructures
{access_token, refresh_token, user} (187), calls setTokens(undefined,
undefined) (192, authSession.ts 51 into authConfig.ts storeTokens 113 to
121, which writes without checking), sets Authorization: Bearer undefined
(195) and calls setUser with a hardcoded emailVerified: true (202) over
the user payload _verification_required_response does include
(auth_router.py 94 to 102), so nothing throws and handlePasswordSubmit
navigates to /dashboard (176 to 182); the refusal surfaces as a 401 on the
next /me (Gotchas); on a real login the body tokens go to localStorage
accessToken and refreshToken, or memory under VITE_USE_COOKIE_AUTH
identity-auth/ui/profile-and-passkeys ProfileWithUsage.tsx: the profile name through api.updateProfile
(PUT /api/user/profile, api.ts 235; page 231) and the password through
api.requestPasswordChangeCode and api.updatePassword (POST
/api/user/password/request-code and PUT /api/user/password, api.ts 240
and 245; page 246 and 274; user_router.py 211 and 251, a six-digit code
held in a process dict, and a hash write that leaves every sessions row
active). Nothing in frontend/src calls POST /api/auth/change-password
or PATCH /api/auth/me, and the page renders no sessions list
ui-design-system/ui/appshell-navigation
/me.surfaces decides what the shell shows (S2); the account block's
"Abmelden" (AppShell.tsx 352) runs handleLogout (136 to 139), which calls
AuthContext.logout (248 to 254: clearAuthStorage, drop the Bearer header,
setUser(null)) and then navigates to /login itself. No request is sent;
api.ts logout() (138 to 147), the one method that would post
POST /api/auth/logout, has no caller in frontend/src, tests included
(grep -rn '\.logout(' src prints nothing; the only mentions in
src/__tests__/integration/auth.integration.test.tsx, 166 to 181, are
comments and one commented-out click)
The inventory's F1 and F5 rows ask for the verification_required and 429 states, POST /change-password with a forced re-login, PATCH /me and a sessions list; those are targets for the two frontend recipes, not what fd8558f renders. The client-side refresh is frontend/src/services/authSession.ts (refreshAccessToken 145 to 154, single-flight; doRefresh 117 onward posts {refresh_token} with a bare axios instance and withCredentials only under the cookie flag). Minimum admin and debug surface: GET /api/auth/me; SELECT id, created_at, last_activity, ip_address, user_agent, acting_email, is_active FROM sessions WHERE user_id = :u ORDER BY created_at DESC; the WARNING Labs login refused for user <id>: no labs grant (148) and Refusing to mint a <surface> session for user <id>: no grant (712 to 716); the INFO line Direct login for user <id> (613). No admin route revokes another user's sessions at fd8558f; two list them read-only under AdminUser (admin_router.py, mounted /api/admin on the Labs surfaces, app_fastapi.py 874): GET /api/admin/active-users (431 to 522) selects active rows joined to users (last_activity, ip_address, user_agent, email and name) over the last hours, and GET /api/admin/user-activity/{user_id} (524 onward, the sessions select at 590 to 600) returns the target's rows (created_at, last_activity, ip_address, user_agent) over the last days. B16 deactivates a user, which S5 turns into 403 on the next request.
Security & tenancy
Auth dependency: none on /login and /refresh (the body is the credential), CurrentUser on /logout, ActiveUser on the rest. Workspace derivation: none here; the workspace reaches the token as tid only when the subject has exactly one (230 to 275) and is read by Prima, not by Labs routes (S6 reads X-Workspace-ID and the memberships). Fail direction, per seam: the password check closed; the labs grant closed (a SurfaceGrantError from get_user_surfaces_standalone is not caught by _require_labs_grant, 140 to 152, so the general handler in app_fastapi.py 790 to 806 answers 500, not the gate's 503, Gotchas); the desk-only question closed (503 at login, propagated at mint); the verification gate closed; the denylist open (off by default, open on a Redis error); the session-row check closed at refresh.
Credentials at rest: passwords as bcrypt cost 12; the two JWS strings verbatim in sessions (a row is a usable credential until exp for the access token and until expires_at or is_active = false for the refresh token); the signing key per B3, never in the database. Rate limits: auth_rate_limit on /login only; /refresh and /logout are unlimited, and /me is unlimited on purpose (scout note, 2026-09-11: the SPA polls it).
Attacker-controlled inputs: email and password (the 401 is one sentence for unknown address and wrong password; a disabled account is a distinguishable 403, accepted); the refresh token in the body (bounded at 8192 bytes by B3, signature first, then the row lookup by equality); the Authorization header and the access_token cookie on /logout (a forged token is a 401 before revoke_session runs; a valid stolen token can log its owner out, which is the least an attacker holding it can do); User-Agent and the client IP written to the row unfiltered (ip_address varchar(50), user_agent text); first_name, last_name, company on PATCH /me (length-bounded by the schema).
Tenancy of the credential: a session belongs to one user and one surface; the personal workspace is created for the user at login when missing (_ensure_workspace, owner-only check, never for a desk-only account); a shared desk account's sessions are partitioned by acting_email so one colleague's sixth device cannot sign out another (721 to 727, 1310 to 1316).
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 Postgres):
test_refresh_verification.py(4 tests, all pass in the 19-test run recorded in B1):test_refresh_unverified_is_refused_service(77,ValueErrorfromrefresh_session),test_refresh_unverified_is_401_http(85),test_refresh_after_verification_succeeds(95),test_refresh_unverified_succeeds_when_flag_off(114). The suite registers through the real route, verifies throughget_db_context(), and mints throughcreate_session, becauserefresh_sessionopens its own session and cannot see the test savepoint (docstring 12 to 16).test_login_verification.py(8 tests, 7 pass, B1 records the harness failure): the 200verification_requiredbody and its missing cookies (66, 83), the resend (94), the wrong password 401 (111), login after verification (121), the legacy flag (158).test_surface_scoped_access.py(44 tests, part of89 passed in 7.33swith the two files B4 names):test_minting_a_token_without_a_surface_is_a_typeerror(209),test_decoding_without_an_expected_surface_is_a_typeerror(218),test_login_requires_an_explicit_labs_grant(544: a correct password with thelabsrow deleted is 403),test_create_session_refuses_a_surface_the_user_has_no_grant_for(557),test_refresh_cannot_launder_a_prima_session_into_a_labs_one(568). The whole file passes (44 passed, measured three times on a fresh database on 2026-09-12), but one of its tests is not order-independent and a-ksubset can fail on it: see Gotchas.test_prima_ed25519_interop.py(39 tests,39 passed in 2.94s): the claim set as Prima reads it (160, 217, 235),test_a_refresh_token_is_refused_as_a_bearer_credential(458).test_desk_acting_email.py:aemat login and after refresh,dskcontainment (B20; not re-run here).- Known-answer expectations for a rebuilt service: a login body decodes to the sorted claim keys
['aud', 'exp', 'iat', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver']for a one-workspace user,exp - iat == 3600on the access token and2592000on the refresh token with the defaults,aud == "labs",ver == 2; the two cookies carry the paths above; after logout the row'sis_activeisfalseand a refresh with its token is 401"Session not found or expired".
Hermetic versus live: all hermetic; the denylist is covered by tests/v2/test_f2_token_revocation.py (11 tests, 91 to 187: flag off is a no-op, a denylisted jti fails decode_token, the entry's TTL is the remaining life, Redis down fails open) with the Redis client stubbed. Playwright gate: frontend/e2e/auth.spec.ts (S2) has Registration, Login (valid, invalid, remember-me), Logout and Password Reset (7 to 115) and no refresh step; its remember-me test (82 to 93) asserts a cookie named refreshToken, which the backend never sets (the cookie is refresh_token, _set_auth_cookies 59 to 79), so that test cannot pass as written.
Deploy & ops
Config keys per app heredoc (S13): JWT_ACCESS_TOKEN_EXPIRE_MINUTES, JWT_REFRESH_TOKEN_EXPIRE_DAYS, MAX_SESSIONS_PER_USER, JWT_DENYLIST_ENABLED (off on the estate, wiki platform/primaapi.md "The auth seam, as built"), REDIS_URL, ENVIRONMENT=production (which is what makes the cookies Secure), the B3 keys, the two verification flags. JWT_SECRET_KEY and JWT_REFRESH_SECRET_KEY are still asserted by scripts/pre_deploy_tests.py and sign nothing (core/config.py 59 to 68).
Migrations order: sessions in baseline_prod_schema_001 (1784 to 1795); sessions.acting_email in 20260905_0001_desk_domain_login.py (73) before any build that mints aem, because create_session writes the column unconditionally (763). Both are in the chain at fd8558f.
Scheduler jobs: none; rows past expires_at stay in the table (no cleanup job exists at fd8558f; _cleanup_user_sessions only deactivates). Argus checks: POST /api/auth/login with the synthetic owner answers 200 with access_token and two set-cookie headers; GET /api/auth/me with that token answers 200 and surfaces contains labs; POST /api/auth/refresh with the pair's refresh token answers 200 with a different access_token; SELECT count(*) FROM sessions WHERE is_active AND expires_at > now() is bounded by MAX_SESSIONS_PER_USER times the active users. Rollback: the routes have no migration of their own; a key change is B3's rollback; lowering MAX_SESSIONS_PER_USER deactivates surplus rows at the next login of each user, not at deploy.
Gotchas
- 2026-09-12 (measured at fd8558f): two cookie writers and one clearer.
auth_router._set_auth_cookies(59 to 79) sets noDomain;core/session_cookies.set_auth_cookies(64 to 113), used by the passkey login (webauthn_router.py145), setsDomain=<surface.cookie_domain>on a same-site response;_clear_auth_cookies(82 to 85) deletes by name and path with noDomain. Browsers key a stored cookie on name, path and the host-only flag as well as the domain (Chrome and Firefox keep theDomain=form under a leading dot; RFC 6265 section 5.3 steps 6 and 11 alone would have the two replace each other, so the RFC is the wrong citation here and this line is reasoned from browser behaviour, not measured in the Playwright harness yet), so a logout after a passkey login writes an expiry for the host-only cookie and leaves theDomain=labs.alchemy.zuerichone in place until itsMax-Age. The consequence is not a double sign-out:get_current_user(api/dependencies.py187 to 245) never readssessions, it decodes the token and checksusers.is_activeonly, so withJWT_DENYLIST_ENABLEDoff (the estate) the leftoveraccess_tokencookie stays a live credential until itsexp, up to 60 minutes after sign-out, exactly as acceptance check 8 records for the bearer case; with the denylist on it is 401. Fix: one writer (core/session_cookies.py) and a clearer that repeats its attributes. - 2026-09-12 (measured at fd8558f):
/logoutneeds a valid access token (CurrentUser, 673). With an expired access token the call is 401,revoke_sessionnever runs, and the refresh token keeps minting for up to 30 days. The one client method that posts here,api.tslogout()(138 to 147), sends{refresh_token}in the body, which the handler ignores (682 to 687); that method has no caller infrontend/src, tests included (grep -rn '\.logout(' srcprints nothing; next Gotcha). Fix: read the refresh token (body or the/api/authcookie) underOptionalUser, verify its signature, and deactivate the row byrefresh_tokenas well. - 2026-09-12 (measured at fd8558f): the SPA never calls
POST /api/auth/logout. "Abmelden" in the shell (AppShell.tsx136 to 139) runsAuthContext.logout(248 to 254), which clears storage, drops the header and nulls the user;App.tsxhas no/logoutroute. Every sign-out therefore leaves an activesessionsrow whose refresh token mints for up to 30 days, and the denylist, if it were on, would never be written from a sign-out. Fix:AuthContext.logoutawaitsapi.logout()(best effort, then the clear) so the row is deactivated while the access token is still valid. - 2026-09-12 (measured at fd8558f): the SPA treats a
verification_required200 as a successful login.AuthContext.login(182 to 210) destructures{access_token, refresh_token, user}from a body that carries none of the first two (187), callssetTokens(undefined, undefined)(192;authSession.ts51 hands straight toauthConfig.tsstoreTokens113 to 121, which writes what it is given), setsAuthorization: Bearer undefined(195), and callssetUserwith a hardcodedemailVerified: true(202) over theuserpayload_verification_required_responsedoes include (auth_router.py94 to 102). Nothing throws, sohandlePasswordSubmit(LoginAlchemy.tsx176 to 182) navigates to/dashboardand the unverified account sits in the shell until the first/meanswers 401. Fix: branch onresponse.data.status === "verification_required"beforesetTokensand route to the "check your email" screen. - 2026-09-12 (measured at fd8558f):
sessions.access_tokenandsessions.refresh_tokenhold the raw JWS. A read of the table is a live session for every active row (the refresh half for 30 days).api_keysstores SHA-256 and looks up by hash (S5); the sessions table does not. Fix: storesha256(token)in both columns, look up by hash inrefresh_session(809 to 818) andrevoke_session(863 to 866); the UNIQUE constraints and indexes carry over unchanged. - 2026-09-12 (measured at fd8558f): with
JWT_DENYLIST_ENABLEDon, the access token lives 15 minutes (auth_service.py428 to 432) while the cookie'sMax-Age(69) and the body'sexpires_in(628, 662) still say 60. A client that trustsexpires_inrefreshes 45 minutes late. Fix: derive both from the minted token'sexp. - 2026-09-12 (measured at fd8558f):
change_passworddeactivates every session row (868 to 875) and denylists nothing, so every access token the user holds stays valid untilexp(60 minutes) even with the flag on; only refresh is stopped.revoke_session(856 to 875) andreset_password(B6) do call the denylist. Fix: iterate the rows and_denylist_tokeneach pair before deactivating. - 2026-09-12 (measured at fd8558f): the 400
"Please complete your account setup by setting a password first"branch (474 to 479) is unreachable:authenticate_userreturnsNonefor aNULLhash becauseverify_passworddoes (357 to 359), so a passwordless account is 401 like a wrong password. The message survives from the passwordless-registration era. Fix: delete the branch, or move theNULLcheck before the password check if the distinct message is wanted (it is an enumeration signal; the recipe says delete). - 2026-09-12 (measured at fd8558f):
_require_labs_grantcallsget_user_surfaces_standalone, which raisesSurfaceGrantErroron a database error (surface_service.py40 to 47); the route does not catch it, so a grants-table outage at login is a 500 from the envelope, whereas the gate answers 503"Access control is temporarily unavailable."for the same condition (surface_gate.py165 to 172). Both are closed; the status differs. Fix: catch and answer 503 with the gate's sentence. - 2026-09-12 (measured at fd8558f):
users.last_loginis written byauthenticate_user(677 to 679) before the grant, verification and active checks, so an account that is refused still shows a freshlast_login. Fix: move the write aftercreate_sessionreturns. - 2026-09-12 (measured at fd8558f):
PATCH /mereturnsUserResponse.model_validate(user)without settingsurfaces(843), so its body carriessurfaces: []whileGET /mecarries the real list. A client that replaces its user object from the PATCH response loses every surface until the next/me. Fix: share the builder withGET /me. - 2026-09-12 (measured at fd8558f):
remember_meonUserLogin(auth_schemas.py34) is accepted and read by nothing; the refresh lifetime is 30 days for everyone. Fix: drop the field or make it choose between two refresh lifetimes. - 2026-09-12 (measured at fd8558f):
/refreshreads a JSON body and never therefresh_tokencookie it sets on/api/auth(S5 Gotchas record the same); a cookie-mode client still needs the token in JavaScript, which is what the cookie mode exists to avoid. Fix: fall back to the cookie when the body is empty. - 2026-09-12 (measured at fd8558f):
_cleanup_user_sessionswithout an acting email counts every active row of the user, including rows that carry anacting_email(1318 to 1320 add the clause only when one is given). A password login on a shared desk account (none exists at fd8558f, the accounts are passwordless) would evict colleagues. Fix: partition onacting_email IS NULLwhen none is given. - 2026-09-12 (measured at fd8558f):
get_current_userlogs the first 20 characters of every presented token and the decoded user id at INFO (api/dependencies.py204, 223); the header of a compact JWS is public, but the log line still ties a token prefix to an IP in the access log. A new service logs thejti. Recorded in S5; repeated here because this is the route that mints what gets logged. - 2026-09-12 (measured at fd8558f):
test_decoding_without_an_expected_surface_is_a_typeerror(tests/test_surface_scoped_access.py218 to 222) is not order-independent. It mints withdb=None, so_resolve_tenant_claims(auth_service.py278 to 304) opensget_db_context()and queriesworkspaceson the test database, and that schema exists only after a test that requested thetest_engineordbfixture has built it (tests/conftest.py84 to 108). The suite runs underpytest-randomly3.15.0, so the order is reshuffled per run: measured against a database that starts empty, the whole file passes (44 passed, three runs) because 42 of its tests build the schema first, but the test alone fails withpsycopg2.errors.UndefinedTable: relation "workspaces" does not exist, and so does a-ksubset when the seed puts it first (-p no:randomlypins that order and prints1 failed, 4 passed, 39 deselected). The recipe's acceptance check named the green outcome of a coin flip. Fix the test, not the runner: have it requestdb(or stub_resolve_tenant_claims), so the assertion aboutdecode_tokenstops depending on a fixture it does not name. No code changed in this wave. - 2026-09-12 (measured at fd8558f, code wins over the wiki):
platform/architecture.md(6cce08b) "Authentication & Authorization" says HS256 viapython-josewithjtifor invalidation. Since ca227b9 (2026-08-03) the signer iscore/jwt_ed25519.py;jtiinvalidation exists only behindJWT_DENYLIST_ENABLED, which is off. The lifetimes in the table (1 h, 30 d) are right.
Reference implementation
Paths are relative to the repo root; the commit is measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f):backend/api/routers/auth_router.py:_set_auth_cookies(59 to 79),_clear_auth_cookies(82 to 85),_verification_required_response(88 to 114),_require_labs_grant(140 to 152),_requested_surface(155 to 173),_ensure_workspace(176 to 232),login(436 to 640),refresh_token(642 to 669),logout(671 to 695),get_current_userat/me(697 to 715),list_my_workspaces(733 to 820),update_profile(823 to 843),change_password(845 to 877).backend/services/auth_service.py: denylist (37 to 164), legacy window (172 to 195),CLAIM_VERSIONand_tenant_claims(227 to 275),_resolve_tenant_claims(278 to 304),SurfaceMismatch,SurfaceAccessDenied(328 to 337),hash_password,verify_password(345 to 363),_mint(366 to 404),create_access_token(407 to 439),create_refresh_token(442 to 471),_verify_signature(474 to 490),_decode_unverified_audience(493 to 521),resolve_token_surface(524 to 544),decode_token_with_surface(547 to 556),decode_token(559 to 577),authenticate_user(662 to 681),create_session(683 to 769),refresh_session(771 to 854),revoke_session(856 to 875),_denylist_token(878 to 897),_cleanup_user_sessions(1307 to 1332).backend/core/session_cookies.py(32 to 35, 49 to 61, 64 to 113);backend/core/jwt_ed25519.pyandbackend/core/jwt_keys.py(B3).backend/api/dependencies.py:_extract_token(35 to 50),get_current_user(187 to 266).backend/models/user_model.py:Session(157 to 184);backend/schemas/auth_schemas.py:UserLogin,TokenResponse,TokenRefresh,UserResponse,UserUpdate,PasswordChange(31 to 97).backend/core/config.py: 59 to 68, 117 to 118, 388 to 389, 417 to 434, 890.backend/migrations/versions/baseline_prod_schema_001.py(1784 to 1795),backend/migrations/versions/20260905_0001_desk_domain_login.py(73).backend/tests/test_refresh_verification.py,backend/tests/test_login_verification.py,backend/tests/test_surface_scoped_access.py(209, 218, 544, 557, 568),backend/tests/test_prima_ed25519_interop.py(160, 217, 235, 458);backend/tests/conftest.py(40 to 47, 56 to 108: the session-scopedtest_enginethat builds and drops the schema).backend/api/routers/admin_router.py(31, 431 to 522, 524 to 600: the read-only session listings);backend/api/routers/user_router.py(211 to 290: the code-based password change the SPA uses);backend/tests/v2/test_f2_token_revocation.py(91 to 187).frontend/src/contexts/AuthContext.tsx(182 to 218, 248 to 254:loginends at 210, the catch at 211 to 218),frontend/src/services/api.ts(138 to 147, 235 to 246),frontend/src/services/authSession.ts(117 to 154),frontend/src/config/authConfig.ts(39, 90 to 121),frontend/src/components/AppShell.tsx(49, 136 to 139, 352),frontend/src/pages/LoginAlchemy.tsx(171 to 189),frontend/src/pages/settings/ProfileWithUsage.tsx(231, 246, 274),frontend/e2e/auth.spec.ts(7 to 115).
- Wiki
philosophers_stone(6cce08b; ADR-0020 re-read at 37c6543 on 2026-09-12, line 151):platform/architecture.md("Authentication & Authorization", "Surfaces"),operations/decisions/0020-prima-identity-and-prepaid-credits.md,operations/decisions/0019-store-selection-is-explicit.md,operations/prima-modules-inventory.md(row B2).
Deviations of the reference from this recipe, all dated 2026-09-12: two cookie writers and a clearer that matches one of them; logout that needs a live access token and ignores the refresh token; a SPA sign-out that never calls it; a SPA that reads verification_required as a successful login; raw tokens in sessions; expires_in and the cookie age wrong under the denylist; change-password without a denylist call; the dead 400 branch; the 500-not-503 on a grants outage at login; last_login before the refusals; PATCH /me without surfaces; the inert remember_me; /refresh blind to its own cookie; the eviction clause for a passwordless shared account; token prefixes at INFO; one surface test that depends on a schema it does not ask for; the wiki's HS256 row.
Acceptance checks
Run from alchemy_labs/backend at the commit in measured_on with the :5434 Postgres up and the app on $B, or from a new service built to this recipe. $T and $R are the access and refresh tokens from step 1.
python -m pytest tests/test_refresh_verification.py -q --no-cov -o addopts="" -p no:cacheproviderprints4 passed;python -m pytest tests/test_surface_scoped_access.py -q --no-cov -o addopts="" -p no:cacheproviderprints44 passed. Run the whole file, not a-ksubset:test_decoding_without_an_expected_surface_is_a_typeerrorneeds a schema no fixture of its own builds, and underpytest-randomlya subset passes or fails on the seed (Gotchas). To check just this recipe's surface claims, drop that test from the filter:-p no:randomly -k "labs_grant or refuses_a_surface or launder"prints3 passed, 41 deselected. All three observations measured 2026-09-12 against a database created empty for the run.curl -s -i -X POST $B/api/auth/login -H 'content-type: application/json' -d '{"email":"<verified user>","password":"<pw>"}'shows200, twoset-cookie:lines (access_token=...; HttpOnly; Path=/api; SameSite=lax; Max-Age=3600andrefresh_token=...; HttpOnly; Path=/api/auth; SameSite=lax; Max-Age=2592000, plusSecureon a production host), and a body whoseuser.surfacescontains"labs"and whoseexpires_inis3600.python -c "import base64,json,sys; p=sys.argv[1].split('.')[1]; d=json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))); print(sorted(d), d['aud'], d['type'], d['ver'], d['exp']-d['iat'])" $Tprints['aud', 'exp', 'iat', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver'] labs access 2 3600for a one-workspace user (notidfor zero or several); the same on$Rprintsrefreshand2592000.curl -s -i -H "Authorization: Bearer $R" $B/api/auth/meis401"Could not validate credentials"(a refresh token is not a bearer credential);curl -s -H "Authorization: Bearer $T" $B/api/auth/meis200with"surfaces":[...]and"acting_email":"<the account's email>".curl -s -X POST $B/api/auth/refresh -H 'content-type: application/json' -d "{\"refresh_token\":\"$R\"}"is200with a new pair; the old$Rposted again is401"Session not found or expired"(the row now holds the new token);SELECT count(*) FROM sessions WHERE user_id = <id> AND is_activeprints1.- Delete the user's
labsgrant row and post the new refresh token:401"Invalid refresh token: no access grant for surface 'labs'"; restore the row and the same token is200. Log in again with the row deleted:403"This account is not permitted to sign in to Alchemy Labs.". - Log in six times:
SELECT count(*) FROM sessions WHERE user_id = <id> AND is_activeprints5, and the oldest row'sis_activeisfalse. curl -s -i -X POST $B/api/auth/logout -H "Authorization: Bearer $T"is204with twoset-cookie:lines that expire the cookies; the row'sis_activeisfalse; the pair's refresh token is401on/refresh;$Ton/meis still200until itsexpwith the denylist off (the documented deviation),401"Could not validate credentials"with it on.curl -s -i -X POST $B/api/auth/change-password -H "Authorization: Bearer $T" -H 'content-type: application/json' -d '{"current_password":"<pw>","new_password":"<pw>"}'is422(must differ; without the header curl sends a form body and every case is422); with a wrong current password400"Current password is incorrect"; with a valid new one204and every row for the user is inactive.UPDATE users SET is_active = false WHERE id = <id>:/mewith a live token is403"User account is disabled"; login is403"Account is disabled".grep -n "create_access_token\|create_refresh_token" api/routers/auth_router.py | wc -lprints0;grep -c "surface=LABS" api/routers/auth_router.pyprints4(the legacy register branch at 403, login at 618, the desk magic link at 1077, dev-login at 1708). On a new service the first command prints0and every mint site names its surface.grep -n "set_cookie\|delete_cookie" api/routers/auth_router.py core/session_cookies.py | wc -lprints6at fd8558f (two writers, one clearer); a new service prints3, all in one module.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.