Auth dependency pattern
_shared/auth-dependency-pattern
| Kind | shared |
|---|---|
| Applies to | backend |
| State | written |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, ed25519-jws, cryptography-46, bcrypt-5, sqlalchemy-sync, postgres-15, redis-6 |
| Depends on | _shared/fastapi-project-layout |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
A handler names the caller it needs (CurrentUser, ActiveUser, VerifiedUser, AdminUser, WorkerUser, OptionalUser) and gets a User row or a 401, with the credential read in one fixed order (Bearer header, then the access_token cookie, then X-API-Key) and the surface it belongs to already checked before the handler's own dependency runs. The invariant that makes it trustworthy is that authorisation is a row and not a claim: a token proves who signed it, user_surface_grants decides where that person may be, and the grant is read live on every gated request, so revoking a grant takes effect on the next call rather than at token expiry.
This foundation fixes the credential order, the dependency vocabulary, the v2 claim set every Labs token carries (iss, aud, ver, type, jti, iat, exp, sub, tids_n, optionally tid, aem, dsk), the Ed25519 signing rule with no algorithm dispatch, the opt-in API-key scope matrix with its prefix-locked gateway scopes, and the two doors the metered gateway accepts.
When to use
Include it from every kind: backend recipe that has an authenticated route. Read it before adding a dependency to a handler, before minting a token anywhere, before adding a scope, and before answering "why does a valid token get 403 here". Trigger phrases: "which dependency", "admin only", "API key or session", "the token is valid but", "where does the surface come from", "what is in the JWT", "can Prima verify this", "the key works on one host and not the other".
Do not use it for the workspace a route acts on (_shared/workspace-scoping, S6: WorkspaceID and X-Workspace-ID), for the HMAC seams between services (_shared/hmac-seam-pattern, S10: POST /internal/* is signed, never a session), for the login screens (identity-auth/ui/auth-screens, F1) or for the client-side half (_shared/react-app-skeleton, S2: /me.surfaces and the single-flight refresh). Passkeys, magic links, OIDC and the desk domain sign-in are identity-auth recipes B5 to B8 and B20; they all end in create_session, which is where this recipe starts.
Stack assumptions
Pinned as measured at fd8558f (2026-09-11): fastapi==0.118.2, cryptography==46.0.0 (the only signing dependency), bcrypt==5.0.0 (passwords, 72-byte truncation in hash_password), python-jose==3.5.0 kept for the JWTError exception type only (services/auth_service.py lines 14 to 19: it signs and verifies nothing, and tests/test_prima_ed25519_interop.py::test_no_hs256_signing_path_remains_in_auth_service greps to keep it so), redis==6.4.0 for the optional denylist. Foundations assumed: S4 (the surface gate is a mount-time dependency), S6, S7 (the 401 and 403 bodies are the Labs envelope; under /api/prima/v1 the OpenAI object).
Environment names and their fail behaviour (core/config.py):
| Name | Default | Behaviour |
|---|---|---|
JWT_SIGNING_KEY_SOURCE | ephemeral | one of env, file, ephemeral; ephemeral refused in staging and production (validator 241 to 259; core/jwt_keys.py 263 to 270) |
JWT_ED25519_PRIVATE_KEY | empty SecretStr | PKCS#8 PEM (literal \n accepted) or a base64url 32-byte seed; empty with source env raises at boot (jwt_keys.py 276 to 282) |
JWT_ED25519_PRIVATE_KEY_PATH | empty | read with source file; unreadable raises at boot (284 to 296) |
JWT_ED25519_PUBLISHED_PUBLIC_KEYS | empty list | extra public keys published and accepted, never used to sign: the rotation mechanism (106 to 109; procedure in jwt_keys.py 39 to 63) |
JWT_ISSUER | the Labs origin | the iss claim; compared with != on both sides (113 to 116) |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES | 60 | access lifetime; 15 when the denylist is on (auth_service.py 55, 428 to 432) |
JWT_REFRESH_TOKEN_EXPIRE_DAYS | 30 | refresh lifetime and the sessions.expires_at horizon |
JWT_LEGACY_NO_AUD_UNTIL | empty | ISO-8601 instant until which a token with no aud is read as labs; empty means reject (127 to 130; auth_service.py 172 to 195) |
JWT_DENYLIST_ENABLED | off | jti denylist in Redis on logout and password reset; off or Redis down means expiry only (auth_service.py 37 to 169) |
ENABLE_EMAIL_VERIFICATION, REQUIRE_EMAIL_VERIFICATION | flags | VerifiedUser checks is_verified only when the first is on (dependencies.py 351 to 362); refresh refuses an unverified account only when both are on (auth_service.py 800 to 810) |
ENVIRONMENT | development | POST /api/auth/dev-login exists only in development (auth_router.py 1685); cookies are Secure only in production (session_cookies.py 77) |
Data model
Tables this layer owns (columns as declared in the models; the migrations are S8's):
-- the allowlist: no row, no access. models/user_surface_grant_model.py lines 40 to 62
user_surface_grants (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
surface varchar(32) NOT NULL,
granted_at timestamp NOT NULL,
granted_by uuid NULL,
PRIMARY KEY (user_id, surface),
CHECK (surface IN ('labs', 'prima_web', 'prima_chat', 'prima_dev')) -- ck_usg_surface
);
CREATE INDEX ix_user_surface_grants_user_id ON user_surface_grants(user_id);
-- one row per minted pair. models/user_model.py lines 158 to 175
sessions (
id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token text UNIQUE NOT NULL, refresh_token text UNIQUE,
ip_address varchar(50), user_agent text, is_active boolean, expires_at timestamp NOT NULL,
created_at timestamp, last_activity timestamp, acting_email varchar(320) NULL
);
-- the key of record is the hash. models/api_key_model.py lines 25 to 60
api_keys (
id uuid PRIMARY KEY, user_id uuid NOT NULL, workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE CASCADE,
key_hash varchar(255) UNIQUE NOT NULL, key_prefix varchar(20) NOT NULL,
permissions jsonb, -- {"scopes": [...]}; empty or absent means unlimited
expires_at timestamptz NULL, is_active boolean NOT NULL DEFAULT true,
max_uses integer NULL, uses_count integer NOT NULL DEFAULT 0, suspended_until timestamptz NULL
);
CREATE INDEX idx_api_keys_user_active ON api_keys(user_id, is_active);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX idx_api_keys_workspace ON api_keys(workspace_id);
What is not stored: the raw API key (returned once at mint, developer_router.py 128 to 131: prima_pk_ plus secrets.token_urlsafe(32), SHA-256 into key_hash, the first twelve characters plus an ellipsis into key_prefix); a JWT_ALGORITHM (there is none, core/config.py 69 to 79); a private key in the JWKS (jwt_ed25519.public_jwk has no code path that emits d, and test_jwks_contains_no_private_material asserts it against the live response).
The claim set every Labs token carries (auth_service.py::_mint, lines 366 to 405, and create_session, 683 to 769; commit ca227b9 for Ed25519 and v2, 2026-08-03; c923b8a for aem and dsk, 2026-09-05):
| Claim | Value | Who reads it |
|---|---|---|
iss | settings.JWT_ISSUER | Labs (_decode_unverified_audience 511 to 516), Prima (allowlist, !=) |
aud | one surface id, a string, never a list | the gate (decode_token_with_surface), Prima (enforce_audience) |
ver | 2 (CLAIM_VERSION, line 228) | Prima refuses ver < 2 (prima/auth/claims.py MIN_CLAIM_VERSION = 2, line 60) |
type | access or refresh | both sides; a refresh token presented as a bearer credential is refused on both (test_a_refresh_token_is_refused_as_a_bearer_credential, 458) |
jti | uuid4 | the denylist when on; Prima caches authorisation per jti |
iat, exp | integer seconds | Labs checks exp (498 to 502); Prima checks both against 60 s skew and a 24 h ceiling (claims.py 63 to 68) |
sub | users.id | everyone |
tids_n | number of workspaces the subject owns or is an active member of | Prima's three-way tenant branch (_tenant_claims 230 to 276) |
tid | workspaces.id, present only when tids_n == 1, never mapped | Prima's tenant and metering hash chain; the docstring at 233 to 242 says why it is never re-keyed |
aem | the acting email of a shared account, present only when it differs from users.email | request.state.acting_email, ActingEmail, the audit trail; never a permission (create_session 700 to 707, 729 to 731) |
dsk | 1 when every active membership is a desk-only role | request.state.desk_only and the containment fence (create_session 733 to 749) |
The JOSE header is {"alg": "EdDSA", "typ": "JWT", "kid": <thumbprint>} (jwt_ed25519.py 218); kid is the RFC 7638 thumbprint of the public key, so it is stable across restarts and computable by the verifier (test_kid_is_a_stable_thumbprint_of_the_key, 644). A token is at most 8192 bytes (MAX_TOKEN_BYTES, line 93); one minted 2026-09-11 with {sub} and no tenant was 440 bytes.
Request state written by the dependency (read by S6 and by handlers): request.state.surface, request.state.surface_grants (set by the gate, surface_gate.py 197 to 198), request.state.acting_email, request.state.desk_only (set by get_current_user, dependencies.py 211 to 219).
API surface
Routes this layer owns, all mounted on ALL_SURFACES at /api/auth unless noted (auth_router.py):
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
POST /api/auth/login | none, auth_rate_limit | none | 200 {access_token, refresh_token, user}; 401 bad credentials; 403 disabled, no password, or no labs grant (_require_labs_grant, 140 to 152, called at 483) | mints via create_session(surface="labs"); sets the two cookies |
POST /api/auth/refresh | none | none | 200 new pair; 401 on any ValueError from refresh_session | the request is a JSON body {"refresh_token": "<token>"} (TokenRefresh, schemas/auth_schemas.py lines 42 to 43; the handler at auth_router.py 642 to 667 takes token_data: TokenRefresh and nothing else), so the refresh_token cookie on /api/auth is written by _set_auth_cookies and never read by this route, and a client in cookie mode still sends the body; inherits the old token's aud, re-reads the grant, re-derives aem and dsk from the session row (771 to 854) |
POST /api/auth/logout | CurrentUser | none | 204 | revoke_session: session row inactive, both jti denylisted when the flag is on |
GET /api/auth/me | ActiveUser | none | 200 UserResponse with surfaces: sorted(get_user_surfaces_standalone(user.id)) (711) | the frontend's authorisation contract (S2) |
POST /api/auth/ws-ticket?purpose= | CurrentUser | none | 200 {ticket, expires_in}; 403 without a labs grant (1649); 400 bad purpose | the surface check happens at issuance because the WebSocket handshake carries the ticket, not the JWT |
GET /api/auth/cli-login | none | none | redirect flow | mints a scoped key; _require_labs_grant at 1557 |
POST /api/auth/dev-login | none | none | 200 or 404 | registered only when ENVIRONMENT == "development" (1685); signs in the first admin |
POST /api/developer/keys | CurrentUser | the key's workspace_id | 201 with the raw key once; 400 on an unknown scope; 403 when a Prima-surface session asks for anything but ai (108 to 126) | self-service scopes {read, write, delete, ai}, default ["read"] (developer_router.py 35 to 36) |
GET /.well-known/prima-jwks.json | none | none | 200, 304 | jwks_router.py; max-age=120 under Prima's 300 s refresh (52); mounted before the catch-all |
Error shape on the dependency's own refusals: HTTPException with a string detail, rendered by S7's handler as {error, code: INVALID_CREDENTIALS or FORBIDDEN, request_id, timestamp}; the 401 carries WWW-Authenticate: Bearer (dependencies.py 194 to 198). Detail strings a client can key on: "Could not validate credentials" (any bad or missing credential, on purpose one string), "User account is disabled" (403), "Inactive user", "Please verify your email address", "Admin access required", "Worker access required", "API key is deactivated" (401), "API key scope does not permit <METHOD> on this resource." (403), "This API key is restricted to the <scope> agent API (<prefix>)." (403), {"error": "desk_only_role"} (403, a dict detail), and from the gate "This session belongs to the '<surface>' surface and cannot be used here.", "This account is not permitted on this surface." (403) and "Access control is temporarily unavailable." (503).
The gateway door (D3: prima is the reference; the in-Labs copy is legacy and is described only where it differs): CurrentIdentity = Annotated[Identity, Depends(current_identity)] (prima/api/auth.py 117 to 144) takes Authorization, X-API-Key and X-Workspace-ID as headers and returns an Identity with subject_id, tenant_id, issuer, auth_method, scopes, surfaces, key_id, degraded, subject_active, cached_until, aud and no PII (prima/seams/identity.py 284 to 313). GET /api/prima/v1/whoami (152 to 184) returns that plus identity_provider: {federation_enabled, jwks_keys, jwks_age_seconds, revocation_lag_seconds, feed_state}. In Labs the legacy equivalent is GatewayCtx = Depends(gateway_identity) on primaapi_router.py line 55, resolving through services/primaapi/identity.py::resolve_gateway_identity (193 to 236) to a GatewayIdentity(user, tenant_id, api_key, auth_method) (63 to 68).
Application logic
get_current_user(request, db) (dependencies.py 187 to 266), in order:
_extract_token(request)(35 to 50):Authorization: Bearer <t>wins; else theaccess_tokencookie; else none.- With a token:
auth_service.decode_token_with_surface(token, token_type="access")(547 to 557), which is signature against the published key set (_verify_signature, 474 to 491:InvalidTokenbecomes one uniformJWTError("Invalid token")), thenexp,iss,type, denylist (_decode_unverified_audience, 493 to 522), thenresolve_token_surface(524 to 545: noaudislabsinside the legacy window and aJWTErroroutside it; a list is refused; an unknown or external surface is refused). The dependency reads the surface rather than asserting one because which surfaces are acceptable is the router's decision (comment at 203 to 207).request.state.surface,acting_emailanddesk_onlyare set from the payload; a missingsubor anyJWTErroris the uniform 401. auth_service.get_user_by_id(sub); no row is the uniform 401;not user.is_activeis 403._enforce_desk_only_containment(request, dsk)(160 to 184): a desk-only account outside/api/desk/and/api/auth/(services/service_desk/access.py::desk_only_path_allowed, 405 to 419) is 403{"error": "desk_only_role"}. Return the user.- Without a token,
X-API-Keyif present:_authenticate_via_api_key(53 to 157): SHA-256 the value, look upapi_keys.key_hash(never the prefix); unknown key returnsNoneand the caller 401s;not is_activeis 401"API key is deactivated"; then the two scope rules below; then the owner row (Noneif missing); thenapi_key_security_service.check_api_key_security(expiry with auto-suspend, suspension, IP allow-list, use count, anomaly; aFalseis 401 with its message); thennot user.is_activeis 403. The desk-only question is asked with one query because there is nodskclaim, andDeskAccessUnavailableis 503 (241 to 258). - Neither credential: the uniform 401 with
WWW-Authenticate: Bearer.
The two API-key scope rules (both in _authenticate_via_api_key):
- Prefix lock (70 to 105):
_GATEWAY_SCOPES = {"airiel": "/api/airiel/", "aurum": "/api/aurum/", "ai": "/api/prima/v1/", "mail": "/api/mail/"}. A key whose scopes carry one of these is refused with 403 on any path outside that prefix. Opt-in per key; a key without such a scope is unaffected. - Method matrix (108 to 127): scopes are enforced only when the key declares any (empty means unlimited, for keys minted before scopes existed);
admin,fullor*grants everything; otherwiseGET,HEAD,OPTIONSneed one ofread,write,delete;DELETEneedsdeleteorwrite; every other verb needswrite.airielis excluded from the enforceable list andaurumdeliberately is not, so anaurumkey that somehow passed the prefix lock still fails every method (the comment at 113 to 119 explains the asymmetry).
The derived dependencies (338 to 386) each take the one below: ActiveUser re-checks is_active (403 "Inactive user"); VerifiedUser adds is_verified when ENABLE_EMAIL_VERIFICATION; AdminUser adds is_admin; WorkerUser admits is_admin or role in ("designer", "delivery"). OptionalUser (388 to 423) decodes a token if present and returns None on any failure; a desk-only account outside its allow-list is None here, not 403, so a public page keeps working for someone signed in at a desk in the same browser. OptionalUser reads no API key. ActingEmail (782 to 794) is request.state.acting_email or user.email, lowercased.
Before any of that runs, the mount-time gate (surface_gate.py::SurfaceGate.__call__, 108 to 199): read the same credential (Bearer, cookie, then X-API-Key, 74 to 79 and 113 to 114); anonymous passes; an undecodable token passes so the endpoint produces the 401 rather than the gate leaking a 403; a token whose aud is not in the router's set is 403; then get_user_surfaces(db, sub) (surface_service.py 33 to 50, the only reader of the table, fail-closed with SurfaceGrantError becoming 503); an API key is not surface-bound, so its owner must hold a grant for at least one surface the router opens, and the lowest-sorted such surface becomes request.state.surface (174 to 187); a token's aud must itself be granted (188 to 195). The key lookup the gate calls is _api_key_user_id(db, value) (94 to 104): SHA-256 the value, SELECT user_id FROM api_keys WHERE key_hash = :h AND is_active = TRUE, None when nothing matches, and on None the gate returns without a decision (158 to 161) so the endpoint's own dependency answers the uniform 401. A service without an API-key door keeps the function and returns None unconditionally; the gate then passes every X-API-Key through to the endpoint, which has no key path and answers the 401. SurfaceExempt("public") delegates to a gate over all four surfaces so a credential presented on a public router is still checked, and own_auth is a true no-op (226 to 242).
Minting (auth_service.py): create_session(user_id, ip, ua, *, surface, acting_email=None) (683 to 769) asserts the surface is issuable, reads the grants and raises SurfaceAccessDenied when the surface is not among them (the choke point every login flow shares, including magic links and password resets), trims sessions per (user_id, acting_email) to MAX_SESSIONS_PER_USER, builds {sub, aem?, dsk?}, mints access and refresh with the same key and the same aud, and inserts the sessions row. refresh_session (771 to 854) decodes the refresh token with its surface, re-reads the grant, finds the active row by the refresh token value, re-derives aem from the row and dsk from the memberships, and mints a new pair with the same surface. create_access_token and create_refresh_token take surface as a mandatory keyword; omitting it is a TypeError (test_minting_a_token_without_a_surface_is_a_typeerror, 209).
Cookies (core/session_cookies.py): access_token on path /api, refresh_token on path /api/auth, HttpOnly; same-site with the surface's cookie_domain, SameSite=Lax, Secure in production; cross-site with no domain, SameSite=None; Secure, and the tokens also in the body because the browser may drop the cookie (64 to 113 and the header comment). The SPA uses the body tokens (S2, cookie mode off).
The gateway door on Prima (prima/seams/identity.py::resolve, 567 onward): a Bearer that decodes is honoured first; a Bearer that does not decode falls through to X-API-Key when one is present, and when none is present the Bearer value itself is tried as the key, because every OpenAI SDK sends Authorization: Bearer <key> and cannot be stopped without a sentinel (the comment at 580 to 596; the same fix in Labs at identity.py 197 to 213). A key must carry ai (authorize_gateway, 459 onward; GATEWAY_SCOPE), a wildcard is a migration defect and is refused, and a suspended subject is refused on both doors from prima_subjects.status read fresh on every request (FIND-011). A JWT is verified offline against the disk-cached JWKS and then authorised once per jti through an introspector; at cd2c248 current_identity passes introspector=None (prima/api/auth.py 137), so a Labs Bearer with no cached session answers 503 identity_provider_unavailable with Retry-After: 30 (prima/auth/sessions.py 100 and 222 to 231). Only the X-API-Key door is complete end to end today (Gotchas).
Invariants
- Credential order is Bearer, then cookie, then
X-API-Key, in the dependency (35 to 50, 223 to 258), in the gate (74 to 79, 113 to 114) and in_resolve_request_user_id(576 to 619); a request with both a JWT and a key is the JWT. - One algorithm, checked by equality:
header["alg"] != "EdDSA"fails injwt_ed25519._split(line 248) and there is no accepted-algorithm list anywhere (_verify_signaturedocstring, 475 to 482).alg: noneand HS256 signed with the public key are refused by tests 299 to 359 of the interop file. - No token without a surface:
_mint,create_access_token,create_refresh_token,create_sessionanddecode_tokenall takesurfaceorexpected_surfaceas a mandatory keyword.decode_token_with_surfaceis the only reader that does not assert one, and it is called only by code that compares the result against a set. - No grant, no access:
get_user_surfacesis the one reader ofuser_surface_grants(test_the_grants_table_is_read_in_exactly_one_module, 716), it filters rows throughISSUABLE_SURFACESso a retired surface's row re-opens nothing (48 to 50), and every error becomes a deny. - Grants are read live on every gated request and at every refresh, never carried in the token (
refresh_session785 to 790; wikiplatform/architecture.md"Surfaces", 2026-08-02). - A refresh inherits the presented token's
aud; it cannot launder one surface into another (test_refresh_cannot_launder_a_prima_session_into_a_labs_one, 568). aemis a label,dskis a fence; nothing authorises onaem(_acting_emaildocstring), anddskis re-derived at refresh so a role change lands at the next refresh, not at expiry (830 to 842).- The API key of record is its SHA-256; lookup is by hash, never by prefix, in the dependency (61 to 64), the gate (97 to 104) and Prima.
- An empty scope list means unlimited, for keys that predate scopes; a key that declares scopes has them enforced; a gateway scope locks the key to its prefix.
POST /api/developer/keysrefuses an empty or unknown scope list and never mints a gateway scope other thanai(test_gateway_scopes_are_not_mintable,test_self_service_keys_are_never_unscoped). - A Prima-surface session mints
ai-only keys, whatever it asked for (developer_router.py108 to 126). - The uniform 401: every bad credential, missing credential, unknown
kid, bad signature and missingsubanswers"Could not validate credentials"; only a known, active key or user distinguishes its refusal (deactivated, disabled, scope, prefix, desk-only). dev-logincannot exist in a production process (registered underif settings.ENVIRONMENT == "development"), and the SPA's call to it is dead in a production bundle (S2).
Frontend
The frontend modules that render this layer, by id, and what each shows of it:
identity-auth/ui/auth-screens login, magic link, passkey, reset: every path ends in create_session;
the 403 "not permitted to sign in" is the missing labs grant
identity-auth/ui/profile-and-passkeys sessions list, change password, passkeys per relying party
identity-auth/ui/api-keys-settings-and-developer-dashboard
scope picker over _SELF_SERVICE_SCOPES, raw key rendered once into one node
identity-auth/ui/admin-users-console grants per surface as a column, appoint and revoke admin
identity-auth/ui/developer-portal-keys-page the ai-only mint from a prima_dev session
identity-auth/ui/chat-signin-and-workspace-picker
the per-session gateway key the chat server mints (B21)
ui-design-system/ui/appshell-navigation /me.surfaces decides what the shell shows; PrivateRoute's
"This account can't be used here" is hasSurface false (S2)
Minimum admin and debug surface: GET /api/auth/me (the grants as surfaces), GET /api/prima/v1/whoami on the gateway (which door, which tenant, how fresh), the JWKS document, and the boot log line JWKS mounted at /.well-known/prima-jwks.json (alg=EdDSA, source=<env|file|ephemeral>, active kid=<kid>, N key(s) published) (app_fastapi.py 1066 to 1073). The three gate refusals log a warning with the path, the surface and the grants (surface_gate.py 143 to 148, 179 to 185, 189 to 194).
Security & tenancy
Auth dependency: this recipe. Workspace derivation: S6, which reads request.state this layer writes and re-validates X-Workspace-ID for the API-key path. Fail direction, per seam: signature, expiry, issuer, type and audience closed; grants closed (503 on a lookup error); desk-only closed (503 when the question cannot be answered, both at mint and in the dependency); API-key security checks closed in the main dependency and open in the legacy in-Labs gateway (services/primaapi/identity.py 175 to 182 logs and continues on an exception); the jti denylist open (off by default, open on a Redis error: is_jti_revoked 147 to 169); the legacy no-aud window closed when unset.
Credentials at rest: the private key in an environment variable or a file, never in the database and never logged (--generate prints it once); the JWKS carries public members only; sessions.access_token and refresh_token are stored verbatim and unique, which is what revoke_session and refresh_session look up by; API keys as SHA-256; passwords as bcrypt with 12 rounds (BCRYPT_ROUNDS).
Rate limits on the credential-issuing routes: auth_rate_limit at 30 per minute per client IP and 10 per minute per email (api/dependencies/rate_limit.py 26 to 34), 429 with Retry-After; TRUSTED_PROXY_COUNT decides which X-Forwarded-For hop is the client.
Attacker-controlled inputs: the Authorization header and the cookie (bounded at 8192 bytes before base64 decoding); X-API-Key (hashed before any lookup); the JOSE header (alg equality, kid looked up in a dict and never scanned, test_unknown_kid_is_rejected_rather_than_scanning_every_key, 409); a list-valued aud (refused); X-Workspace-ID (S6); the Origin and Host headers on the cookie path (is_same_site, session_cookies.py 49 to 61, a suffix match against cookie_domain).
Tenancy of the credential: a JWT belongs to one surface (aud) and to a user; the workspace is not in the token except as tid when unambiguous, and tid is read by Prima only. An API key belongs to a user and to one workspace (api_keys.workspace_id), and the gateway's tenant is that column (identity.py 190; Prima prima_api_keys likewise). A key minted from a Prima surface reaches the gateway and nothing else; a key minted from Labs with ai and other scopes reaches the gateway under the prefix lock and the rest of the API under the method matrix.
Tests
Backend (alchemy_labs/backend/tests/, run 2026-09-11 at fd8558f against the :5434 Postgres):
-
test_surface_scoped_access.pyandtest_surface_rp_id.py:84 passed in 8.27s. The cases a new service copies: the twoTypeErrortests (209, 218),test_a_prima_token_does_not_validate_as_a_labs_token(249), the legacy window pair (258, 274),test_a_token_naming_an_unknown_surface_is_rejected(289),test_api_only_session_is_denied_on_every_labs_gated_route(354, walks the real route table),test_api_only_session_403s_over_real_http(400, parametrised),test_self_service_keys_are_never_unscoped(440),test_login_requires_an_explicit_labs_grant(544),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),test_a_labs_user_may_still_use_the_prima_gateway(638),test_every_route_carries_a_surface_decision(680),test_the_grants_table_is_read_in_exactly_one_module(716). -
test_prima_ed25519_interop.pyandtest_admin_api_key_scopes.py:59 passed in 3.52s. Known-answer vectors: a Labs-minted token verified by Prima's verifier (160),tidequals the workspace id unmapped (217),tids_nagainst membership (235), the algorithm refusals (299 to 359), the rotation sequence (576 to 625),test_labs_and_prima_agree_on_the_surface_vocabularyand_on_the_algorithm_name(698, 708). -
A known-answer vector for the primitive itself, so a rebuilt
jwt_ed25519.pycan be shown to interoperate with Prima's verifier before any service runs (computed 2026-09-12 withcore/jwt_ed25519.pyat fd8558f and verified withprima/auth/jwt_ed25519.py::verifyat cd2c248; the twin files to carry it arealchemy_labs/backend/tests/test_prima_ed25519_interop.pyandprima/tests/auth/test_jwt_ed25519.py, each with the comment naming the other). Seed000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f(32 bytes,bytes(range(32)); as aJWT_ED25519_PRIVATE_KEYvalue it is the base64url seedAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8); publicxA6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg;kid1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y(43 characters); payload{"aud":"labs","exp":1800000600,"iat":1800000000,"iss":"https://issuer.example","jti":"00000000-0000-4000-8000-000000000002","sub":"00000000-0000-4000-8000-000000000001","tids_n":0,"type":"access","ver":2}; the compact token, 466 characters:eyJhbGciOiJFZERTQSIsImtpZCI6IjFJRzJ0TUg3SjJ3Ykpabk9mOExKelFpdEtmN0xNdm9BRWxzdURNVk01NFkiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJsYWJzIiwiZXhwIjoxODAwMDAwNjAwLCJpYXQiOjE4MDAwMDAwMDAsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUiLCJqdGkiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJzdWIiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0aWRzX24iOjAsInR5cGUiOiJhY2Nlc3MiLCJ2ZXIiOjJ9.HmXLKLkui_V4er4rRca5RVjWuKDDMlbL7rqgokt7f3B5_9JfSD7svPXrpLk4MRIFFdUcK955QOimBKuJTKRQAAThe token is reproducible because Ed25519 signs deterministically and header and payload are both compact JSON with sorted keys (
_compact_json,jwt_ed25519.pyline 132; the header is{"alg":"EdDSA","kid":<kid>,"typ":"JWT"}).encode(claims, private_key=Ed25519PrivateKey.from_private_bytes(seed), kid=thumbprint_kid(public_key))must print it byte for byte;verify_signature(token, {kid: public_key})on Labs andverify(token, jwk_to_public_key({"kty":"OKP","crv":"Ed25519","x":<x>,"kid":<kid>}))on Prima must both return the payload above; a change to any constant is a wire break the twin catches. -
test_desk_acting_email.py:aemanddskend to end, includingOptionalUserreturningNoneoff the desk allow-list (592 to 604). -
test_webauthn_multi_rp.py,test_primaapi_gateway.py,test_aurum_gateway.py: the neighbours (B7, the legacy gateway, the prefix lock foraurum).
Prima (prima/tests/auth/, run 2026-09-11 at cd2c248 with python -m pytest tests/auth -q -o addopts=""): 232 passed, 21 skipped in 5.99s (the skips are test_repository_live.py, which needs a database). Load-bearing: test_import_graph.py (no module on the key path can reach Labs, exactly one module imports labs_internal, no auth module imports a JWT library), test_labs_down.py (the degradation table: identity_provider_unavailable with Retry-After: 30), test_gateway_scope_uniform.py (an ai key is authorised the same on every channel; a wildcard is refused), test_subject_suspension.py (FIND-011), test_claims_and_surfaces.py (28 cases on the claim floor and audience rules).
Hermetic versus live: everything above is hermetic apart from the 21 skipped Prima tests; no test reaches a deployed host. Playwright gate: frontend/e2e/auth.spec.ts (S2) walks login, refresh and the not-permitted screen.
Deploy & ops
Config keys per app heredoc (S13): JWT_SIGNING_KEY_SOURCE=env and JWT_ED25519_PRIVATE_KEY (one line, base64url seed or PEM with literal \n), JWT_ISSUER equal to Prima's PRIMA_JWT_ISSUER byte for byte, JWT_ED25519_PUBLISHED_PUBLIC_KEYS empty until a rotation, JWT_LEGACY_NO_AUD_UNTIL empty (reject), JWT_DENYLIST_ENABLED as decided (off today), MAX_SESSIONS_PER_USER, the two verification flags, and on Prima PRIMA_JWKS_URL pointing at the API host's /.well-known/prima-jwks.json (not the SPA host, which answers 200 with index.html for any path; deploy/infomaniak/PRIMA_JWKS_INGRESS.md records the trap).
Migrations order: user_surface_grants before any code that mints (account creation writes the grant row in the same transaction as the user row, models/__init__.py 80 to 84); sessions.acting_email before c923b8a's code. Both are in the chain at fd8558f (S8).
Rotation, from core/jwt_keys.py lines 39 to 63: publish the new public key in JWT_ED25519_PUBLISHED_PUBLIC_KEYS and deploy; wait at least Prima's refresh interval (300 s); move the new private key into JWT_ED25519_PRIVATE_KEY and the old public key into the published list and deploy; after JWT_ACCESS_TOKEN_EXPIRE_MINUTES plus skew, remove the old key and deploy. A compromised key is steps three and four back to back, which signs out everyone holding a token from it. test_rotation_publishes_both_keys_and_signs_with_exactly_one (576) and the two that follow pin the sequence.
Scheduler jobs: none owned here; session cleanup is _cleanup_user_sessions at mint time. Argus checks: the JWKS document returns 200 with keys[0].kty == "OKP" and crv == "Ed25519" and no d; GET /api/auth/me with a known session returns surfaces containing labs; the gateway's whoami reports jwks_keys >= 1. Rollback: a key change is configuration, reverted by redeploying the previous values; a token minted by a key that is no longer published is a 401 for its holder, which is the documented outcome of step four.
Gotchas
- 2026-09-11 (measured at fd8558f, code wins over the wiki):
platform/architecture.md(d4f5b95) "Authentication & Authorization" says JWT is HS256 viapython-josewith access 1 h and refresh 30 d. Since ca227b9 (2026-08-03) tokens are Ed25519 JWS signed bycore/jwt_ed25519.py;python-joseis imported forJWTErroronly,JWT_SECRET_KEYsigns nothing, and there is noJWT_ALGORITHMsetting (core/config.py60 to 79). The lifetimes are right. - 2026-09-11 (measured at fd8558f, code wins over the wiki):
platform/primaapi.md(d4f5b95, "Open items") says Labs' half of the auth seam does not exist in any branch and names/.well-known/prima-jwks.jsonamong the missing pieces. The JWKS endpoint has been in Labs since ca227b9 (api/routers/jwks_router.py, mounted atapp_fastapi.py1050 to 1062). The other two pieces the page names, an introspection endpoint and a revocation feed, do not exist at fd8558f (grep -rln introspectunderbackend/outside tests hits onlyservices/primalayered), so Prima's Bearer door still runs withintrospector=Noneand answers 503identity_provider_unavailable. Prima's ownprima/auth/wiring.pyheader (cd2c248) still says Labs signs HS256 and that none of the four endpoints exists; one of the four does. - 2026-09-11 (measured at cd2c248):
prima/auth/wiring.pyline 24 andtests/auth/test_labs_down.pyline 17 describe the unavailable-provider answer as 401;_unavailable()inprima/auth/sessions.py(222 to 231) builds it with status 503. The test asserts the code and the header, not the status. The code wins: 503 withRetry-After: 30. - 2026-09-11 (measured at fd8558f):
JWT_DENYLIST_ENABLEDdefaults to off and the wiki (platform/primaapi.md, "The auth seam, as built") records that it is set nowhere on the estate, sorevoke_jtiis a no-op and logout does not end a still-valid access token; with the flag on, a Redis error fails open (is_jti_revoked164 to 169). The session row goes inactive at logout, which stops refresh but not the access token for its remaining life (60 min; 15 min once the flag is on). - 2026-09-11 (measured at fd8558f): the wiki's own note (
platform/architecture.md, verified 2026-09-04) that API-key scopes are enforced opt-in per key, by HTTP method, is what the code does (dependencies.py108 to 127). Two consequences worth naming:POST /api/developer/keysdefaults to["read"], so a key minted without asking foraiauthenticates on the gateway and then fails every completion with 403"This API key does not carry the 'ai' scope required for PrimaAPI."(legacyidentity.py150 to 158), which reads as a broken gateway; and a key withpermissionsunset is unlimited, whichGET /api/auth/cli-loginused to produce (prima/seams/identity.py158 to 162 records it;test_self_service_keys_are_never_unscopednow guards the developer route). - 2026-09-11 (measured at fd8558f): the legacy in-Labs gateway door (
services/primaapi/identity.py) is the one that answers on the Labs hosts; it accepts a wildcard scope (_grant_all = {"admin", "full", "*"}, 151 to 152) that Prima'sprima_api_keysrefuses by CHECK constraint (ck_prima_api_keys_no_wildcard,prima/auth/models.py244 to 246), and its security-service call fails open (175 to 182). Both are why D3 names the standalone service as the reference: a new gateway copiesprima/seams/identity.py, not this file. - 2026-09-11 (measured at fd8558f):
SurfaceExempt("public")did no checking on 12 mounts until the 2026-08-02 audit (surface_gate.py226 to 233 tells the story; wikioperations/security-audit-2026-08-02.mdB1). It now delegates to a four-surface gate. A public router can therefore still refuse a credential: a token minted for a surface its owner is not granted is 403 on a beacon path, which is correct and surprising. - 2026-09-11 (measured at fd8558f): the
api/dependencies/package shadowsapi/dependencies.py(S4 Gotchas);from api.dependencies import CurrentUserresolves throughapi._dependencies_impl. A test that patchesapi.dependencies.auth_servicepatches the wrong module object. - 2026-09-11 (measured at fd8558f):
get_current_userlogs the first 20 characters of every presented token at INFO (dependencies.py204) and the decoded user id (223). A new service logs neither; thejtiis the handle. - 2026-09-11 (measured at fd8558f):
RateLimitDependencyandrate_limit_strictin the same file are placeholders (S4 Gotchas);auth_rate_limitinapi/dependencies/rate_limit.pyis the real one and is what/login,/register,/forgot-password,/reset-password,/request-magic-link,/resend-verification,/verify-emailand/set-passworddeclare. - 2026-09-11 (measured at fd8558f): the surface question for a WebSocket is asked at
POST /api/auth/ws-ticket(alabsgrant, 1649), not at the handshake;websocket_routeris mountedsurface_exempt("own_auth")for that reason (app_fastapi.py880). A Prima realtime feature needs its own purpose and its own grant check, not a widening of this one. - 2026-09-11 (measured at fd8558f):
check_ai_accessandAIAccessUser(489 to 543) gate the Labs copilot onclient_statusand the metal tier with 402 and 403; zero routers declare them (grep overapi/andservices/). They are the pre-D2 billing gate; D2 (2026-09-11) makes the prepaid credit in Prima's ledger the one truth for AI spend, so a new AI route meters through the gateway and does not copy this dependency. - 2026-09-11 (measured at fd8558f): the inventory's row B6 names a second password-change flow in
user_router.py(a six-digit code held in a process-local dict); D8 asks to retire it. Until then two ways to change a password exist and only/api/auth/change-passwordand/reset-passwordrevoke sessions. Not re-measured here; the row is the source. - 2026-09-05 (c923b8a, the
Surfaceattribute saga recorded incore/surfaces.py53 to 122 and wikiplatform/architecture.md): the WebAuthn service readrp_id,rp_nameandoriginsoff aSurfacethat declared none of them; each was added only when a live 500 exposed it, and passkey registration was broken on every surface from 2026-08-29 to 2026-09-04. The three are@propertyaliases now withtests/test_surface_rp_id.pycovering every surface. When a fix adds one attribute, grep every attribute the caller reads.
Reference implementation
Paths are relative to each repo's root; commits as in measured_on and also_measured_on.
alchemy_labs(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/dependencies.py:_extract_token(35 to 50),_authenticate_via_api_key(53 to 157;_GATEWAY_SCOPES70 to 96, method matrix 108 to 127, security service 139 to 146),_enforce_desk_only_containment(160 to 184),get_current_user(187 to 266),get_db_with_rls(268 to 336),get_current_active_user(338),get_current_verified_user(351),get_current_admin_user(364),get_current_worker_user(377),get_optional_current_user(388 to 423),check_ai_access(489 to 543), theAnnotatedaliases (565 to 574),_resolve_request_user_id(576 to 619),_acting_emailandActingEmail(782 to 794). Call sites at fd8558f:CurrentUser202,ActiveUser172,AdminUser173,VerifiedUser58,WorkerUser3,Depends(get_current_user)204,Depends(get_db)181,DatabaseSessionWithRLS22.backend/api/dependencies/__init__.py(the shadow, 4 to 39),backend/api/dependencies/rate_limit.py(26 to 34,auth_rate_limit38),backend/api/dependencies/surface.py(resolve_surface: explicit id, thenOrigin, thenReferer, thenlabs).backend/api/surface_gate.py:SurfaceGate(63 to 199),SurfaceExempt(202 to 252),mount_router(265 to 274).backend/core/surfaces.py: the five ids (34 to 38),Surfacewith the three aliases (41 to 122),SURFACES(125 to 166),ISSUABLE_SURFACES,PRIMA_SURFACES,LABS_SURFACES,ALL_SURFACES,LABS_SIGNUP_SURFACES,API_ONLY_SURFACES(169 to 189),assert_issuable(211 to 220),validate_surfaces(223 to 235).backend/core/jwt_ed25519.py:ALG(78),MAX_TOKEN_BYTES(93),InvalidToken(97),public_jwk(141),thumbprint_kid(167),encode(206 to 227),_splitwith the equality check (229 to 267, line 248),peek_kid(269),verify_signature(280 onward).backend/core/jwt_keys.py: the source rule and rotation procedure (1 to 63),KeyConfigurationError(107),load_private_key(127),generate_private_key_pem(180),SigningKeyandKeySet(199 to 243),build_key_set(245 to 325),get_key_set(333 to 344),--generate(353 to 368).backend/core/session_cookies.py: names and paths (32 to 35),is_same_site(49 to 61),set_auth_cookies(64 to 113).backend/services/auth_service.py: denylist (37 to 169), legacy window (172 to 195),CLAIM_VERSIONand_tenant_claims(200 to 305),Redemption(308 to 325),SurfaceMismatch,SurfaceAccessDenied(328 to 337),_mint(366 to 405),create_access_token(407 to 440),create_refresh_token(442 to 472),_verify_signature(474 to 491),_decode_unverified_audience(493 to 522),resolve_token_surface(524 to 545),decode_token_with_surface(547 to 557),decode_token(559 to 577),create_session(683 to 769),refresh_session(771 to 854),revoke_session(856 to 876).backend/services/surface_service.py:SurfaceGrantError(29),get_user_surfaces(33 to 50),user_has_surface(53 to 57),grant_surfaces(60),revoke_surfaces(95), the standalone pair (114 to 132).backend/models/user_surface_grant_model.py(40 to 62),backend/models/user_model.py(Session158 to 175),backend/models/api_key_model.py(25 to 60).backend/api/routers/auth_router.py:_require_labs_grant(140 to 152),login(436, grant at 483),refresh(642),logout(671),me(697 to 732),cli_login(1510),create_ws_ticket(1623 to 1660),dev_login(1685 onward).backend/api/routers/developer_router.py:_SELF_SERVICE_SCOPES(35), the Prima-surface rule (108 to 126), the mint (128 to 131).backend/api/routers/jwks_router.py:_MAX_AGE_SECONDS(52),JWKS_PATH(57), the handler (60 onward).backend/services/primaapi/identity.py(legacy, D3):GatewayIdentity(63 to 68),_authenticate_ai_scoped_key(139 to 190),resolve_gateway_identity(193 to 236);backend/api/routers/primaapi_router.pyGatewayCtx(55).backend/services/service_desk/access.py:DeskAccessUnavailable(75),is_desk_only_user(367),desk_only_path_allowed(405 to 419).- Tests as listed under Tests.
prima(feature/per-model-pricing, cd2c248):prima/api/auth.py(current_identity117 to 144,CurrentIdentity144,whoami152 to 184),prima/auth/claims.py(floors 60 to 74,Claims84 to 102),prima/auth/surfaces.py(GATEWAY_SURFACES83,enforce_audience93 onward),prima/auth/sessions.py(RETRY_AFTER_S100,_unavailable222 to 231,_revoked234 to 239),prima/auth/jwt_ed25519.py(the mirror-image verifier),prima/auth/wiring.py(the one module that may importlabs_internal),prima/seams/identity.py(Identity284 to 313,authorize_gateway459 onward,resolve567 onward,_resolve_jwt691 onward),tests/auth/.- Wiki
philosophers_stone(d4f5b95):platform/architecture.md("Authentication & Authorization", the scope note verified 2026-09-04, "Surfaces", the passkey saga),platform/primaapi.md("The auth seam, as built", "Self-serve signup mints a key Prima cannot see", "Open items"),operations/decisions/0020-prima-identity-and-prepaid-credits.md(section 1 surfaces, theaiscope),operations/decisions/0022-prima-extraction.md,operations/prima-modules-inventory.md(rows B2, B3, B4, B10, B11, B13, B20 and S5).
Deviations of the reference from this recipe, all dated 2026-09-11: the denylist off and open; the legacy gateway's wildcard and fail-open security call; token prefixes logged at INFO; the placeholder rate-limit dependency; the second password-change flow; check_ai_access present with zero callers; the introspection endpoint and revocation feed absent, so the Prima Bearer door is 503 by design; three stale docstrings (Prima's wiring header, the test table's 401, the Caddy note the wiki quotes).
Acceptance checks
Run from alchemy_labs/backend at the commit in measured_on with the :5434 test Postgres up, or from a new service built to this recipe. Expected observations are exact.
python -m pytest tests/test_surface_scoped_access.py tests/test_surface_rp_id.py -q --no-cov -o addopts=""prints84 passed;python -m pytest tests/test_prima_ed25519_interop.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts=""prints59 passed. Inprima:python -m pytest tests/auth -q -o addopts=""prints232 passed, 21 skipped.grep -n "user_surface_grants" -r --include='*.py' . | grep -v "tests/\|migrations/\|models/user_surface_grant_model.py\|services/surface_service.py" | wc -lprints0for SQL reads (the remaining hits are comments and the model registration inmodels/__init__.py;test_the_grants_table_is_read_in_exactly_one_moduleis the authoritative form of this check).grep -n "JWT_ALGORITHM\|jwt.encode\|jwt.decode\|jose.jwt" services/auth_service.py core/jwt_ed25519.py | wc -lprints0;grep -c '!= ALG' core/jwt_ed25519.pyprints1.python -c "from services.auth_service import auth_service; auth_service.create_access_token({'sub': 'x'})"(withJWT_SIGNING_KEY_SOURCE=ephemeraland the test environment) raisesTypeErrornamingsurface;python -c "from services.auth_service import auth_service; auth_service.decode_token('x')"raisesTypeErrornamingexpected_surface.- With the app running and a session for a user who holds
labsonly:curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/auth/mereturns"surfaces":["labs"]; delete the user's grant row and repeat: the response is 403 with"This account is not permitted on this surface."; restore the row and the same token works again (no re-login). On a new service the route is its own/api/auth/meand the 403 is observed on any route mounted onLABS_SURFACES. - With a token minted for
prima_web(a Prima magic link, orcreate_session(..., surface="prima_web")in a shell):curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/forms/templatesis 403 with"This session belongs to the 'prima_web' surface and cannot be used here.";curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8000/api/prima/v1/modelsis 200. On a new service the first call is any route mounted onLABS_SURFACES(the sentence is exact); the second applies only when the service mounts a router onALL_SURFACES, where the same token is admitted. curl -s -H "Authorization: Bearer not-a-token" http://127.0.0.1:8000/api/auth/me -ishows401,www-authenticate: Bearerand"error":"Could not validate credentials"; the same with no header at all shows the same three things; the same with a token whose header saysalg: HS256shows the same three things.- (Skip on a new service until it has an API-key door; the reading is then the same.) Mint a key with scopes
["read"]throughPOST /api/developer/keys:GET /api/forms/templateswithX-API-Keyis 200;POSTto the same path is 403"API key scope does not permit POST on this resource.";GET /api/prima/v1/modelswith the same key is 403 naming theaiscope. Mint a key with["ai"]: the gateway call is 200 andGET /api/forms/templatesis 403"This API key is restricted to the ai agent API (/api/prima/v1).". curl -s http://127.0.0.1:8000/.well-known/prima-jwks.json | python -c "import json,sys; d=json.load(sys.stdin); k=d['keys'][0]; print(k['kty'], k['crv'], k['alg'], 'd' in k)"printsOKP Ed25519 EdDSA False.- Decode any freshly minted access token's payload (
python -c "import base64,json,sys; p=sys.argv[1].split('.')[1]; print(sorted(json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))).keys()))" $T): the keys are['aud', 'exp', 'iat', 'iss', 'jti', 'sub', 'tid', 'tids_n', 'type', 'ver']for an ordinary single-workspace user and the same list withouttidfor a user with zero or several workspaces;aemanddskappear only for a shared desk account;veris2; the header'salgisEdDSAand it carries akid. ENVIRONMENT=production JWT_SIGNING_KEY_SOURCE=ephemeral python -c "import core.config"exits non-zero naming the refusal;JWT_SIGNING_KEY_SOURCE=env JWT_ED25519_PRIVATE_KEY= python -c "from core.jwt_keys import get_key_set; get_key_set()"raisesKeyConfigurationErrorcontainingJWT_ED25519_PRIVATE_KEY is empty.- (Skip on a new service until a gateway exists.) In
primawith the service running and no Labs reachable:curl -s -H "Authorization: Bearer $LABS_TOKEN" $PRIMA_BASE/api/prima/v1/whoami -ishows503,retry-after: 30and"code":"identity_provider_unavailable";curl -s -H "X-API-Key: $PRIMA_KEY" $PRIMA_BASE/api/prima/v1/whoami -ishows200with"auth_method":"api_key"and"federation_enabled":false.
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.