Prima Get an API key

Module kit / Identity and auth

Ed25519 JWT and JWKS

identity-auth/ed25519-jwt-and-jwks

Kind backend
Domain identity-auth
Maturity prod
Reusable yes
Stack fastapi, ed25519-jws, cryptography
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/test-layout
Frontends identity-auth/ui/auth-screens
Reference implementation
  • alchemy_labs/backend/core/jwt_ed25519.py
  • alchemy_labs/backend/core/jwt_keys.py
  • alchemy_labs/backend/api/routers/jwks_router.py
  • alchemy_labs/backend/services/auth_service.py
  • alchemy_labs/backend/core/session_cookies.py
  • alchemy_labs/backend/app_fastapi.py
  • alchemy_labs/backend/tests/test_prima_ed25519_interop.py
  • prima/prima/auth/jwt_ed25519.py
  • prima/prima/auth/jwks.py
  • prima/prima/seams/identity.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • prima feature/per-model-pricing cd2c248 2026-09-12
Version 1

Purpose

Labs signs every session token with one Ed25519 private key and publishes the matching public keys at GET /.well-known/prima-jwks.json, so a second service (Prima) can verify a Labs token from its own process without ever holding material that could mint one. The invariant that makes it trustworthy is that there is exactly one algorithm and the check is an equality: header["alg"] != "EdDSA" fails on one line in Labs and on one line in Prima, there is no accepted-algorithm list to widen, kid selects a key and never triggers a scan, and the JWKS is built by a function that has no code path for the private member d.

This recipe covers the compact-JWS primitive (encode, split, verify), the key set and where its material comes from (env, file, ephemeral, stated and never inferred), the published document and its caching, the four-step rotation, and the consumer side as Prima implements it (a disk-first key store that never expires and never fetches on the request path). What the claims mean is B2; who reads the token on each request is S5 and B11.

When to use

Include it from any service that mints or verifies a Labs-issued token, and read it before any of: "generate a signing key", "rotate the key", "Prima says unknown kid", "worker A signs and worker B rejects", "can we add RS256 for a partner", "the JWKS returns HTML", "is d in the document". Trigger phrases: "EdDSA", "kid", "thumbprint", "JWT_SIGNING_KEY_SOURCE", "JWT_ED25519_PUBLISHED_PUBLIC_KEYS", "prima-jwks.json", "JwksStore", "max-age 120".

Do not use it for what the claims mean or how a session is minted and refreshed (B2), for the surface question the aud claim carries (B4), for the two doors the gateway accepts (B11, D3: prima is the reference), for the federation door that needs an introspection endpoint Labs does not have (B13), or for the HMAC seams between services (S10: a signed POST /internal/* is not a JWT). A service that needs a symmetric shared key for anything has left this recipe.

Stack assumptions

Measured at fd8558f (2026-09-12): cryptography==46.0.0 (Ed25519PrivateKey.sign, Ed25519PublicKey.verify, PEM loaders), stdlib base64, json, hashlib; python-jose==3.5.0 present for the JWTError type only and unable to sign EdDSA at any pinned version (core/jwt_ed25519.py 13 to 23; test_no_hs256_signing_path_remains_in_auth_service greps auth_service.py to keep it so). On Prima at cd2c248 the mirror module prima/auth/jwt_ed25519.py is stdlib plus cryptography and nothing else (tests/auth/test_import_graph.py keeps JWT libraries off the auth path). Foundations assumed: S4 (the JWKS router is mounted surface_exempt("public")), S5 (the caller of verify_signature is auth_service._verify_signature), S9.

Environment names and fail behaviour (core/config.py, validated at import; core/jwt_keys.py, validated at first get_key_set(), which app_fastapi.py forces at boot):

NameDefaultBehaviour
JWT_SIGNING_KEY_SOURCEephemeral (87 to 91, pattern ^(env|file|ephemeral)$)ephemeral with ENVIRONMENT in staging, production raises in the settings validator (241 to 259) and again in build_key_set (262 to 269); any other value raises KeyConfigurationError (255 to 260)
JWT_ED25519_PRIVATE_KEYempty SecretStr (92 to 95)with source env: a PKCS#8 PEM (literal \n accepted, _clean 120 to 124) or a base64url 32-byte seed; empty raises (276 to 283); unparseable raises with one sentence per case (127 to 153)
JWT_ED25519_PRIVATE_KEY_PATHempty (96 to 99)with source file: read as UTF-8; empty path or unreadable file raises (284 to 297)
JWT_ED25519_PUBLISHED_PUBLIC_KEYSempty list (106 to 109)comma-separated public keys (SubjectPublicKeyInfo PEM or base64url), published and accepted, never signing; a duplicate of the active key is logged and skipped (305 to 314)
JWT_ISSUERthe Labs origin (113 to 116)the iss claim; logged at boot (316 to 323); compared with != on both sides
ENVIRONMENTdevelopmentthe only thing that makes ephemeral legal
Prima: PRIMA_JWKS_URLrequired, a URL (prima/config.py 485 to 491)fetched by a plain GET with follow_redirects=False, 5 s timeout (prima/auth/jwks.py 258 to 276)
Prima: PRIMA_JWKS_CACHE_PATHrequired (492 to 498)the disk copy loaded before the network on every start; "not a temp file"
Prima: PRIMA_JWKS_REFRESH_SECONDS300, bounds 30 to 86400 (535 to 540)the background interval and the unknown-kid refresh interval
Prima: PRIMA_JWT_ISSUERrequired (479 to 484)must equal JWT_ISSUER byte for byte

Data model

Nothing in the database. The key set is a frozen in-memory object built once per process (core/jwt_keys.py 198 to 231, 332 to 344):

KeySet
  active: SigningKey(kid, private_key, public_key)
  verification_keys: {kid: Ed25519PublicKey}      # always contains the active kid
  source: "env" | "file" | "ephemeral"

The published document (KeySet.jwks_document, 214 to 227; public_jwk, core/jwt_ed25519.py 141 to 164), active key first, the rest sorted by kid; measured 2026-09-12 at 168 bytes for one key, serialised with separators=(",", ":") and sort_keys=True:

{"keys":[{"alg":"EdDSA","crv":"Ed25519","kid":"<43-char thumbprint>","kty":"OKP","use":"sig","x":"<43-char base64url of 32 bytes>"}]}

The JOSE header on every token: {"alg":"EdDSA","kid":<thumbprint>,"typ":"JWT"} (compact JSON with sorted keys, _compact_json 132 to 135, encode 218 to 220). kid is the RFC 7638 thumbprint of the public key, base64url(sha256('{"crv":"Ed25519","kty":"OKP","x":"<x>"}')), 43 characters (thumbprint_kid, 167 to 186), so two processes with the same key compute the same kid and two different keys cannot share one. A token is at most MAX_TOKEN_BYTES = 8192 bytes (93 to 94), three segments (91), a 64-byte signature (84), a 32-byte key (85). The wire format is deterministic: Ed25519 signs deterministically and both JSON segments are compact with sorted keys, which is what makes the known-answer vector under Tests reproducible.

What is not stored, anywhere, by this layer: the private key (an environment variable or a file the operator owns; --generate prints it once and nothing logs it, 180 to 192, 360 to 366); a d member in any JWK (no code path emits one; Prima refuses a document that carries one, jwk_to_public_key 127 to 131); a JWE (five segments are refused, 87 to 91); a JWT_ALGORITHM setting (core/config.py 69 to 79 explains its absence). Prima's disk cache is {"fetched_at": <float>, "document": <the JWKS as fetched>}, written atomically at mode 600 (_persist, 225 to 255).

API surface

RouteAuth depTenancyCodesNotes
GET /.well-known/prima-jwks.jsonnone, mounted surface_exempt("public", ...) (app_fastapi.py 1052 to 1060)none200 application/jwk-set+json; 304 on a matching If-None-Matchheaders on 200: ETag: W/"<first 32 hex of sha256(body)>", Cache-Control: public, max-age=120, Access-Control-Allow-Origin: * (api/routers/jwks_router.py 72 to 96); the 304 carries ETag and Cache-Control only (76 to 83); the body is rebuilt from the key set on every request, never cached in a module global (69 to 72)

Consumers of the document at cd2c248: prima/auth/jwks.py::http_fetcher (unauthenticated GET, no HMAC, no import of the Labs internal seam, 258 to 276) feeding JwksStore.install (177 to 187); GET /api/prima/v1/whoami reports identity_provider.jwks_keys and jwks_age_seconds from the store (prima/api/auth.py 155 to 184). Prima's Bearer door at cd2c248 still runs with introspector=None (prima/api/auth.py, current_identity) and answers 503 identity_provider_unavailable with Retry-After: 30 (prima/auth/sessions.py 100, 222 to 231) for a Labs token it has never introspected; the signature check this recipe describes is the part that works.

Labs-internal callers of the primitive: auth_service._mint (encode with the active key, 402 to 404), auth_service._verify_signature (verify against public_keys_by_kid(), every InvalidToken collapsed into one JWTError("Invalid token"), 474 to 490), auth_service._denylist_token (signature only, to read jti and exp off an expired token, 878 to 897). No route takes an algorithm, a key, or a kid from a caller.

Application logic

Encode (core/jwt_ed25519.py::encode, 206 to 223):

  1. kid must be non-empty and at most 64 characters, else ValueError (215 to 216; test_a_token_without_a_kid_cannot_even_be_minted).
  2. Header {"alg": "EdDSA", "typ": "JWT", "kid": kid} and the claims are each json.dumps(..., separators=(",", ":"), sort_keys=True), base64url without padding (109 to 111).
  3. The signing input is header_seg + "." + payload_seg as ASCII; the signature is private_key.sign(...); the token is the three segments joined by ..

Verify (_split 229 to 266, verify_signature 280 to 307), in order, every failure an InvalidToken whose message never reaches a client:

  1. A non-string or empty token, or one over 8192 bytes, or not exactly three segments.
  2. The header decodes as a JSON object; alg != "EdDSA" (248 to 249, the only algorithm check); typ not in (None, "JWT"); any crit (RFC 7515 section 4.1.11: refuse what is not understood); kid missing, non-string, empty or over 64 characters.
  3. The signature segment decodes to exactly 64 bytes.
  4. public_keys.get(kid); None is a rejection, never a scan (296 to 298; test_unknown_kid_is_rejected_rather_than_scanning_every_key).
  5. public_key.verify(signature, signing_input); only then is the payload decoded (300 to 307), so no unverified claim is ever in a variable.

Key set (core/jwt_keys.py::build_key_set, 245 to 324): the source is read and validated (252 to 260); ephemeral generates a key and logs a WARNING that every restart signs everyone out (270 to 274); env reads and parses the private key; file reads the path; the active SigningKey is built with its thumbprint; every entry of the published list is parsed as a public key, keyed by its thumbprint, and added unless it is the active key; one INFO line names algorithm, source, active kid, published kids and issuer (316 to 323). get_key_set() builds once and caches for the process (332 to 344); app_fastapi.py calls it at import right after mounting the router (1065) so a bad key is a failed boot, and logs JWKS mounted at /.well-known/prima-jwks.json (alg=EdDSA, source=<s>, active kid=<k>, N key(s) published) (1066 to 1073). The mount is deliberately placed before the clean-URL catch-all /{workspace_slug}/{form_slug} (1044 to 1045, 1074 to 1078), which would otherwise swallow the two-segment path.

Rotation (the procedure in core/jwt_keys.py 32 to 61, pinned by the three rotation tests at 576 to 640):

  1. Generate a pair (python -m core.jwt_keys --generate prints kid, the private PEM and the public PEM). Put the public PEM in JWT_ED25519_PUBLISHED_PUBLIC_KEYS. Deploy. The JWKS advertises {old (signing), new (idle)}.
  2. Wait at least PRIMA_JWKS_REFRESH_SECONDS (300 s). Prima's scheduled refresh picks up the new key, and at cd2c248 that tick is the only thing that will (Gotchas: a token bearing a key Prima has not fetched yet does not pull it in); nothing signs with it yet.
  3. Move the new private key into JWT_ED25519_PRIVATE_KEY and the old public key into the published list. Deploy. The JWKS advertises {new (signing), old (idle)}; tokens minted seconds before under the old key still verify everywhere.
  4. After JWT_ACCESS_TOKEN_EXPIRE_MINUTES plus skew (an hour by default; the refresh tokens minted under the old key are the longer tail, Gotchas), remove the old key from the list. Deploy. A token bearing the old kid is now refused by both services.

A compromised key is steps 3 and 4 back to back, which signs out every holder of a token from it.

Consumer (prima/auth/jwks.py, cd2c248): JwksStore(cache_path) starts empty and loads the disk envelope before the network is touched (load_from_disk, 189 to 223; a missing, oversize, corrupt or keyless cache is a WARNING, never fatal); refresh_once(store, fetch) (279 to 296) swallows every fetch failure and every rejected document and keeps the last good set; start_background_refresh (299 to 325) runs it on a daemon thread every interval_s; install (177 to 187) validates through _parse (82 to 114: an object with a non-empty keys list of at most MAX_KEYS = 20; an entry with a bad kid, a non-OKP kty, a non-Ed25519 crv, a use present and other than sig, an alg present and other than EdDSA (an absent use or an absent alg is accepted: jwk_to_public_key compares against (None, "sig") and (None, ALG), 123 to 126), a d, or a key that is not 32 bytes is counted and skipped; a document with no usable entry raises) and persists atomically at mode 600; key_for(kid) never blocks and never fetches (141 to 144); an unknown kid denies that one token and, at most once per UNKNOWN_KID_REFRESH_INTERVAL_S = 300 globally and never per kid, emits the WARNING jwks_unknown_kid (should_refresh_for_unknown_kid 166 to 173, whose only caller is prima/seams/identity.py::_key_for 810 to 824). The rate limit is real; the refresh it is named for is not, and the module docstring (16 to 29) describes the amplifier the limit prevents as though the refresh ran (Gotchas). There is no TTL on key material anywhere in the module.

Invariants

  • One algorithm, one equality: grep -c '!= ALG' core/jwt_ed25519.py prints 1 at fd8558f and grep -c '!= ALG' prima/auth/jwt_ed25519.py prints 1 at cd2c248; alg: none, alg: HS256, and HS256 signed with the Ed25519 public key as the HMAC key are refused on both sides (interop tests 299 to 371).
  • kid is mandatory on the wire, at most 64 characters, and selects exactly one key; an unknown kid is a rejection on both sides and, on Prima, a rate-limited log line (jwks_unknown_kid, at most one per 300 s) that does not fetch anything (Gotchas).
  • The JWKS carries public members only (public_jwk emits kty, crv, x, use, alg, kid and nothing else; test_jwks_contains_no_private_material 485; test_prima_would_reject_a_jwks_carrying_a_private_member 516).
  • The key source is one of three named values with no fallback chain (test_signing_key_source_has_no_fallback_chain 659); ephemeral cannot boot in staging or production (test_ephemeral_keys_are_refused_outside_development 671).
  • The key set is immutable for the life of the process; a configuration change is a restart.
  • max-age on the document (120 s) is shorter than Prima's refresh interval (300 s), so no intermediary cache can make a published key arrive after it starts signing (jwks_router.py 27 to 34, 51 to 52).
  • A published key never signs; the active key always verifies; during a rotation the document carries both and exactly one signs (test_rotation_publishes_both_keys_and_signs_with_exactly_one 576).
  • The payload is decoded only after the signature verifies, in both implementations (verify_signature 300 to 307; Prima verify 190 to 206).
  • Prima's request path never performs network I/O for a key; a JWKS fetch can fail forever without failing a request (the module's "one rule", 1 to 15).
  • Labs and Prima agree on the algorithm name and the surface vocabulary by test, not by convention (test_labs_and_prima_agree_on_the_algorithm_name 708, ..._surface_vocabulary 698).

Frontend

identity-auth/ui/auth-screens          renders nothing of this layer directly; every token it stores (S2) carries
                                       the EdDSA header this recipe defines, and a 401 after a key rotation's
                                       step 4 is the "session expired, sign in again" state
monitoring-ops/ui/public-status-page   planned (F87, no stub at 2026-09-12): the JWKS reachability check
                                       (200, keys[0].kty == "OKP", no "d") belongs on it

Minimum admin and debug surface: the boot line JWKS mounted at ... (alg=EdDSA, source=<s>, active kid=<k>, N key(s) published) and the INFO line from build_key_set naming the issuer; curl -s $B/.well-known/prima-jwks.json (the kid list is the rotation's state); on Prima, GET /api/prima/v1/whoami for jwks_keys and jwks_age_seconds, and the log events jwks_installed, jwks_loaded_from_disk, jwks_refresh_failed, jwks_refresh_rejected, jwks_entries_rejected, jwks_cache_unusable, jwks_cache_write_failed and jwks_unknown_kid (a WARNING, at most one per 300 s, and the only signal that a kid is on the wire before it is in the set); python -m core.jwt_keys --generate as the operator tool. There is no admin route that rotates a key; rotation is configuration.

Security & tenancy

Auth dependency: none on the document (public by design, jwks_router.py 3 to 16); auth_service is the only Labs caller of the primitive. Workspace derivation: none. Fail direction, per seam: the key configuration closed at boot (a bad key is no process); signature, algorithm, kid, segment count and size closed; Prima's fetch open (a failed refresh keeps the last set), Prima's verification closed (no key for the kid, no token); the 8192-byte bound closed before any base64 work.

Credentials at rest: the private key in JWT_ED25519_PRIVATE_KEY or the file at JWT_ED25519_PRIVATE_KEY_PATH, never in the database, never in a log (the INFO line at boot names kids only); Prima holds public material only, on disk at mode 600 because the cache is a trust anchor, not because it is confidential. Rate limits: none on the document (it is 168 bytes and cacheable; a flood is a bandwidth problem, not a key problem); Prima's unknown-kid handling is a dict miss plus a log line limited to one per 300 s globally, and no fetch follows it (Gotchas).

Attacker-controlled inputs: the token (bounded, three segments, header parsed as JSON then compared by equality, kid looked up in a dict); the JOSE header (alg, typ, crit, kid all checked before any cryptography); on Prima, the fetched document (an object, at most 20 keys, at most 64 KiB, follow_redirects=False, each entry validated and a bad one skipped) and the disk cache (same validation on load). A hostile kid in a flood of tokens costs Prima one dict miss per token and one log line per 300 s; nothing on that path reaches the network, which is the property the rate limit was written for and the one the missing refresh leaves intact.

Tenancy of the key: one key set per Labs deployment, one issuer string; Prima's IssuerRegistry holds one issuer at cd2c248 (B13). A second product that needs its own signing identity gets its own key pair and its own iss, never a share of this one; a key that verifies for one issuer is never used to verify another's tokens because kid and iss are both checked.

Tests

Labs (alchemy_labs/backend/tests/test_prima_ed25519_interop.py, run 2026-09-12 at fd8558f with python -m pytest tests/test_prima_ed25519_interop.py -q --no-cov -o addopts="" -p no:cacheprovider): 39 passed in 2.94s. No database; the tenant lookup is a stub (StubDB, 100); Prima is imported read-only from the checkout named by PRIMA_REPO_PATH, defaulting to ~/localprojects/prima and not to a sibling of the Labs checkout, and the interop cases skip when that path holds no prima/auth/jwt_ed25519.py (PRIMA_ROOT and requires_prima, 73 to 86; the path is appended to sys.path, never prepended, so Labs wins every name collision). Load-bearing cases: test_labs_token_verifies_through_primas_own_verifier (160), test_alg_none_is_rejected_by_labs and _by_prima (299, 308, parametrised), test_hs256_token_is_rejected_by_labs (316), test_hs256_forged_with_the_ed25519_public_key_as_the_secret_is_rejected (333), test_hs256_token_is_rejected_by_prima (359), test_no_hs256_signing_path_remains_in_auth_service (373), test_token_signed_by_a_different_ed25519_key_is_rejected and _by_prima (385, 398), test_unknown_kid_is_rejected_rather_than_scanning_every_key (409), test_a_token_without_a_kid_cannot_even_be_minted (418), test_jwks_contains_no_private_material (485), test_prima_accepts_our_jwks_document_verbatim (505), test_prima_would_reject_a_jwks_carrying_a_private_member (516), test_jwks_endpoint_requires_no_authentication (535: asserts the route's own dependencies, not an HTTP call), the rotation trio (576, 601, 625), test_kid_is_a_stable_thumbprint_of_the_key (644), test_signing_key_source_has_no_fallback_chain (659), test_ephemeral_keys_are_refused_outside_development (671), test_a_pem_with_escaped_newlines_still_loads (677), test_file_source_reads_the_key_from_disk (687), the two vocabulary agreements (698, 708).

Prima (prima/tests/auth/, run 2026-09-12 at cd2c248 with python -m pytest tests/auth/test_jwks.py tests/auth/test_jwt_ed25519.py -q -o addopts="" -p no:cacheprovider): 36 passed in 0.19s (16 and 20 collected). test_jwks.py covers install then key_for (18), persistence and reload (25), the cache mode (35), disk before network (40), a failed refresh never expiring the set (55), a malformed response keeping the previous set (71), corrupt and missing caches not fatal (78, 86), one unusable entry not discarding the document (90), a document with no usable entry raising (98), the 20-key bound (104), the global unknown-kid rate limit (115, 128), key_for never fetching (148), age as a gauge (159), the envelope shape (171).

Known-answer vector, recomputed 2026-09-12 with core/jwt_ed25519.py at fd8558f and verified with prima/auth/jwt_ed25519.py::verify at cd2c248 (the same vector S5 carries; it belongs in both repos' test files with a comment naming the other, and at fd8558f and cd2c248 it is in neither, Reference implementation). Seed 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f (bytes(range(32)); as a JWT_ED25519_PRIVATE_KEY value the base64url seed AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8). That seed exists to be recomputed and never to be deployed: its private half is derivable by anyone reading this line, so a key set whose active kid is the one below is a compromised deployment, not a test fixture in the wrong place. Public x A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg; kid 1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y; 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 two UUID-shaped strings are fixed fixture values that identify nothing and belong to no workspace; the vector reproduces only with them, byte for byte, so they are kept rather than scrubbed); the token, 466 characters:

eyJhbGciOiJFZERTQSIsImtpZCI6IjFJRzJ0TUg3SjJ3Ykpabk9mOExKelFpdEtmN0xNdm9BRWxzdURNVk01NFkiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJsYWJzIiwiZXhwIjoxODAwMDAwNjAwLCJpYXQiOjE4MDAwMDAwMDAsImlzcyI6Imh0dHBzOi8vaXNzdWVyLmV4YW1wbGUiLCJqdGkiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDIiLCJzdWIiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEiLCJ0aWRzX24iOjAsInR5cGUiOiJhY2Nlc3MiLCJ2ZXIiOjJ9.HmXLKLkui_V4er4rRca5RVjWuKDDMlbL7rqgokt7f3B5_9JfSD7svPXrpLk4MRIFFdUcK955QOimBKuJTKRQAA

The JWKS for that key, 168 bytes, is {"keys":[{"alg":"EdDSA","crv":"Ed25519","kid":"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y","kty":"OKP","use":"sig","x":"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"}]} and its weak ETag is W/"04ca9cad2bad5abd32c9eb48334ac0e6". A rebuilt primitive must reproduce all four strings byte for byte.

Hermetic versus live: everything above is hermetic; no test fetches a deployed JWKS. Playwright gate: none; the F1 gate's forced-refresh step exercises a signed token end to end.

Deploy & ops

Config keys per app heredoc (S13), Labs: JWT_SIGNING_KEY_SOURCE=env, JWT_ED25519_PRIVATE_KEY (one line: the base64url seed from --generate, or the PEM with literal \n, and never the published fixture seed under Tests), JWT_ED25519_PUBLISHED_PUBLIC_KEYS (empty outside a rotation), JWT_ISSUER. Prima: PRIMA_JWKS_URL pointing at the API host's /.well-known/prima-jwks.json, PRIMA_JWKS_CACHE_PATH on a volume that survives a restart, PRIMA_JWKS_REFRESH_SECONDS=300, PRIMA_JWT_ISSUER equal to JWT_ISSUER. The SPA host is not the API host: Labs' static site answers 200 with index.html for any path (try_files {path} /index.html), so a PRIMA_JWKS_URL on the SPA host is a permanent jwks_refresh_rejected warning and, from a cold start, an empty store that refuses every Labs token while API-key traffic keeps working (deploy/infomaniak/PRIMA_JWKS_INGRESS.md at fd8558f, "Why that is worse than an outage").

Migrations order: none. Scheduler jobs: none on Labs; on Prima the refresh thread started at boot. Argus checks: GET /.well-known/prima-jwks.json on the API host answers 200 with content-type: application/jwk-set+json, keys[0].kty == "OKP", keys[0].crv == "Ed25519", keys[0].alg == "EdDSA", no d in any entry, and the same kid from every worker across ten fetches (a differing kid is the per-worker ephemeral failure); Prima's whoami reports jwks_keys >= 1 and jwks_age_seconds < 900; the Labs boot log carries the JWKS mounted line with source=env. Rollback: a key change is configuration and reverts 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 4; a Prima that has cached a retired key keeps accepting its still-unexpired tokens until the next refresh replaces the set, which is why step 4 waits.

Gotchas

  • 2026-09-12 (measured at fd8558f, code wins over the wiki): platform/architecture.md (6cce08b) "Authentication & Authorization" lists JWT as HS256 via python-jose. Since ca227b9 (2026-08-03) every token is an EdDSA JWS from core/jwt_ed25519.py; python-jose is imported for JWTError only; there is no JWT_ALGORITHM setting (core/config.py 69 to 79). JWT_SECRET_KEY and JWT_REFRESH_SECRET_KEY remain in core/config.py (67 to 68) because scripts/pre_deploy_tests.py asserts them and the production environment still defines them (59 to 66); they sign nothing. Fix: retire the two settings and the pre-deploy assertion together, as their own change.
  • 2026-09-12 (measured at fd8558f): step 4 of the rotation waits JWT_ACCESS_TOKEN_EXPIRE_MINUTES plus skew (core/jwt_keys.py 54 to 57), but refresh tokens are signed with the same key and live JWT_REFRESH_TOKEN_EXPIRE_DAYS (30). Removing the old key after an hour makes every refresh token minted under it a 401 "Invalid refresh token: Invalid token" at its next use, which the SPA reads as a forced sign-out; a session is not lost (the row stays active and a new login mints under the new key) but a user is signed out. Fix: wait 30 days for a planned rotation, or accept the sign-out and say so in the change; the docstring should name the refresh lifetime.
  • 2026-09-12 (measured at fd8558f): the 304 branch of the JWKS handler (jwks_router.py 76 to 83) returns ETag and Cache-Control and no Access-Control-Allow-Origin, while the 200 branch sets * (94). A browser's conditional cross-origin fetch of the document fails CORS on the 304; Prima fetches server-to-server and is unaffected. Fix: the same header on both branches.
  • 2026-09-12 (measured at fd8558f): get_key_set() is cached at first call (core/jwt_keys.py 339 to 344) and app_fastapi.py calls it at import (1065), so a rotation is a restart of every worker, never a live reload; reset_key_set_for_tests (347 to 350) is the only way to drop the cache and nothing in the request path calls it. The recipe wants exactly this; it is recorded because "I changed the env and the JWKS did not change" is the question it answers.
  • 2026-09-12 (measured at fd8558f): _split_list splits the published list on commas only (core/jwt_keys.py 233 to 242) because a PEM contains newlines; a PEM whose header line the operator pasted with a comma inside a comment, or two base64url keys separated by a newline, parse as one item and raise at boot with Ed25519 public key is neither PEM nor base64url. Fix: publish base64url keys, one per comma.
  • 2026-09-12 (measured at fd8558f): ephemeral in development generates one key per worker (core/config.py 241 to 259 and core/jwt_keys.py 19 to 30 both say so). With WORKERS above 1 on a laptop, roughly three requests in four fail with the uniform 401 and the JWKS differs per fetch. Run one worker in development, or use env with the seed from --generate.
  • 2026-09-12 (measured at fd8558f): PRIMA_JWKS_URL on the SPA host is a silent, permanent, total failure of Labs SSO into Prima (the deploy/infomaniak/PRIMA_JWKS_INGRESS.md note at fd8558f, and Prima's own refresh_once comment on why every fetch failure is swallowed). It does not page because the API-key path keeps working. The Argus check above is the detection; the fix is the API host in the URL.
  • 2026-09-12 (measured at cd2c248): the unknown-kid path does not refresh. JwksStore.should_refresh_for_unknown_kid() (prima/auth/jwks.py 166 to 173) only returns a bool, and its one caller (prima/seams/identity.py::_key_for 810 to 824) does nothing with a True answer except emit jwks_unknown_kid and deny the token; nothing calls refresh_once outside the fixed-interval daemon loop in prima/auth/wiring.py (254 to 258), whose loop is refresh_once(store, fetch); stop.wait(interval_s) (jwks.py 313 to 316). The module docstring promises the refresh twice (16 to 29, "a miss does two things: it asks for an out-of-band refresh (rate-limited) and it denies that one token"; 31 to 36, "the out-of-band refresh, which is rate-limited, is what stands between that and a rotation-shaped outage") and tests/auth/test_jwks.py (115 to 146) asserts only the boolean limiter, never a fetch. The consequence: a key first seen on the wire is honoured only after the next scheduled PRIMA_JWKS_REFRESH_SECONDS tick, so a rotation that starts signing early is an outage of up to one full interval rather than one rate-limited refresh. Fix: hand the True answer to the refresher, with a threading.Event the background loop waits on instead of a bare stop.wait(interval_s), so exactly one rate-limited refresh actually runs; the rate limit and the never-fetch-inline rule both survive that change untouched.
  • 2026-09-12 (measured at cd2c248): prima/auth/wiring.py's header still says Labs signs HS256 and that none of the four seam endpoints exists; the JWKS endpoint has existed since ca227b9 and test_prima_accepts_our_jwks_document_verbatim proves Prima reads it. The introspection endpoint and the revocation feed remain absent at fd8558f, so the Bearer door is 503 by design (S5 Gotchas record the same and the 401-versus-503 docstring drift).
  • 2026-09-12 (measured at fd8558f): public_jwk emits use and alg members (156 to 164); Prima accepts them only as sig and EdDSA (jwk_to_public_key 123 to 126) and Labs' own public_key_from_jwk (189 to 200) ignores both. A rebuilt publisher that emits use: "enc" or an alg other than EdDSA is accepted by Labs and refused by Prima; an absent use or an absent alg is accepted by both, because Prima compares against (None, "sig") and (None, ALG) and only a present-and-wrong value is refused. The known-answer JWKS above is the shape to keep.
  • 2026-09-12 (measured at fd8558f): _verify_signature collapses every InvalidToken into JWTError("Invalid token") (486 to 489), so the Labs log line at api/dependencies.py 228 reads JWT decode error: Invalid token for a bad kid, a bad signature and a truncated token alike. That is the intended client-facing uniformity leaking into the operator's log. Fix: log the InvalidToken message at DEBUG from _verify_signature before re-raising; the client still sees one sentence.

Reference implementation

Paths are relative to each repo's root; commits as in measured_on and also_measured_on.

  • alchemy_labs (branch d1a-token-source at fd8558f):
    • backend/core/jwt_ed25519.py: the rationale (1 to 48), ALG, KTY, CRV (77 to 81), _SIG_LEN, _KEY_LEN, _SEGMENTS, MAX_TOKEN_BYTES (84 to 94), InvalidToken (97 to 103), b64u_encode, b64u_decode (109 to 119), _json_object, _compact_json (122 to 135), public_jwk (141 to 164), thumbprint_kid (167 to 186), public_key_from_jwk (189 to 200), encode (206 to 223), _split (229 to 266), peek_kid (269 to 277), verify_signature (280 to 307).
    • backend/core/jwt_keys.py: the source rule and rotation (1 to 66), VALID_SOURCES (104), KeyConfigurationError (107 to 114), _clean (120 to 124), load_private_key (127 to 153), load_public_key (156 to 177), generate_private_key_pem (180 to 192), SigningKey, KeySet (198 to 230), _split_list (233 to 242), build_key_set (245 to 324), get_key_set (332 to 344), reset_key_set_for_tests (347 to 350), the --generate tool (353 to 368).
    • backend/api/routers/jwks_router.py: the docstring (1 to 35), _MAX_AGE_SECONDS (52), JWKS_PATH (57), prima_jwks (60 to 96).
    • backend/app_fastapi.py: the mount and boot assertion (1040 to 1073), the catch-all after it (1074 to 1078).
    • backend/services/auth_service.py: _mint (366 to 404), _verify_signature (474 to 490), _denylist_token (878 to 897).
    • backend/core/config.py: 59 to 116, 241 to 259.
    • backend/core/session_cookies.py: not on the signing path; listed in reference_impl by the inventory row because the cookie is how the token travels (32 to 35).
    • backend/tests/test_prima_ed25519_interop.py (39 tests as listed).
    • deploy/infomaniak/PRIMA_JWKS_INGRESS.md.
  • prima (feature/per-model-pricing, cd2c248): prima/auth/jwt_ed25519.py (ALG 69, MAX_TOKEN_BYTES 82, InvalidToken 85, jwk_to_public_key 112 to 136, peek_kid 138 to 147, _split 149 to 187, verify 190 to 206), prima/auth/jwks.py (the one rule 1 to 37, MAX_KEYS 69, UNKNOWN_KID_REFRESH_INTERVAL_S 72, MAX_DOCUMENT_BYTES 75, _parse 82 to 114, JwksStore 117 to 255, http_fetcher 258 to 276, refresh_once 279 to 296, start_background_refresh 299 to 325), prima/seams/identity.py (_resolve_jwt 691 to 808, _key_for 810 to 824: the whole consumer path from peek_kid to the unknown-kid log line), prima/auth/wiring.py (the refresher start, 254 to 258), prima/config.py (479 to 498, 535 to 540), prima/api/auth.py (current_identity, whoami 155 to 184), prima/auth/sessions.py (100, 222 to 231), tests/auth/test_jwks.py, tests/auth/test_jwt_ed25519.py, tests/auth/test_import_graph.py.
  • Wiki philosophers_stone (6cce08b): platform/primaapi.md ("The auth seam, as built", "Open items"), platform/architecture.md ("Authentication & Authorization"), operations/decisions/0020-prima-identity-and-prepaid-credits.md, operations/decisions/0022-prima-extraction.md, operations/prima-modules-inventory.md (row B3).

Deviations of the reference from this recipe, all dated 2026-09-12: the two inert HS256 settings and their pre-deploy assertion; the rotation docstring's one-hour wait against a 30-day refresh token; the 304 without a CORS header; the wiki's HS256 row and Prima's wiring header; the operator log that cannot tell a bad kid from a bad signature; Prima's unknown-kid refresh, which the module docstring promises and no code performs; and the known-answer vector, which is in neither repo's test file at fd8558f and cd2c248 (grep -c for the kid prints 0 in both, run 2026-09-12).

Acceptance checks

Run from alchemy_labs/backend at fd8558f, from prima at cd2c248, or from a new service built to this recipe. $B is the Labs API base.

  1. python -m pytest tests/test_prima_ed25519_interop.py -q --no-cov -o addopts="" -p no:cacheprovider prints 39 passed with PRIMA_REPO_PATH pointing at a Prima checkout at cd2c248 (unset, the default is ~/localprojects/prima; with neither, the same command prints the same count with the interop cases as skipped). In prima: python -m pytest tests/auth/test_jwks.py tests/auth/test_jwt_ed25519.py -q -o addopts="" -p no:cacheprovider prints 36 passed.
  2. The known-answer vector: JWT_SIGNING_KEY_SOURCE=ephemeral ENVIRONMENT=development python -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as K; from core import jwt_ed25519 as j; p=K.from_private_bytes(bytes(range(32))); k=j.thumbprint_kid(p.public_key()); print(k); print(j.encode({'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}, private_key=p, kid=k))" prints the kid and the 466-character token under Tests, byte for byte. In prima, verify(<that token>, jwk_to_public_key({"kty":"OKP","crv":"Ed25519","x":"A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg","kid":"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y"})) returns the tuple (header, payload) with the payload above and the header {"alg":"EdDSA","kid":"1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y","typ":"JWT"} (verify is typed -> tuple[dict, dict], prima/auth/jwt_ed25519.py 190). That JWK carries neither alg nor use and Prima accepts it, which is the Gotcha about absent members; adding "use":"enc" to it makes the same call raise JWK is not marked for signature use.
  3. grep -c '!= ALG' core/jwt_ed25519.py prints 1; grep -n "JWT_ALGORITHM\|jwt.encode\|jwt.decode\|jose.jwt" core/jwt_ed25519.py core/jwt_keys.py services/auth_service.py | wc -l prints 0; grep -n '"d"' core/jwt_ed25519.py prints only the refusal in public_key_from_jwk (line 195).
  4. curl -s -i $B/.well-known/prima-jwks.json shows 200, content-type: application/jwk-set+json, cache-control: public, max-age=120, an etag: W/"..." of 32 hex characters, access-control-allow-origin: *, and a body whose only key has kty == "OKP", crv == "Ed25519", alg == "EdDSA", use == "sig", a 43-character kid, a 43-character x, and no d. Repeat with -H 'If-None-Match: <that etag>': 304.
  5. python -c "import json,sys,base64,hashlib; d=json.load(sys.stdin)['keys'][0]; c=json.dumps({'crv':d['crv'],'kty':d['kty'],'x':d['x']},separators=(',',':'),sort_keys=True).encode(); print(base64.urlsafe_b64encode(hashlib.sha256(c).digest()).rstrip(b'=').decode()==d['kid'])" < <(curl -s $B/.well-known/prima-jwks.json) prints True (the kid is the thumbprint).
  6. Ten fetches of the document from a multi-worker deployment print one distinct kid (for i in $(seq 10); do curl -s $B/.well-known/prima-jwks.json | python -c "import json,sys; print(json.load(sys.stdin)['keys'][0]['kid'])"; done | sort -u | wc -l prints 1).
  7. ENVIRONMENT=production JWT_SIGNING_KEY_SOURCE=ephemeral python -c "import core.config" exits non-zero naming ephemeral; ENVIRONMENT=development JWT_SIGNING_KEY_SOURCE=env JWT_ED25519_PRIVATE_KEY= python -c "from core.jwt_keys import get_key_set; get_key_set()" raises KeyConfigurationError containing JWT_ED25519_PRIVATE_KEY is empty; JWT_SIGNING_KEY_SOURCE=vault python -c "import core.config" exits non-zero on the pattern.
  8. A live token with its header rewritten to {"alg":"HS256",...} (re-signed with anything) on GET /api/auth/me is 401 "Could not validate credentials"; the same with {"alg":"none"} and an empty signature is the same 401; the same token with its kid changed by one character is the same 401.
  9. Rotation rehearsal on a scratch deployment: publish a second public key (JWT_ED25519_PUBLISHED_PUBLIC_KEYS) and restart: the document lists two kids with the active one first and a token minted before the restart still verifies; swap the private key and move the old public key into the list and restart: a token minted before the swap still verifies and a new token carries the new kid; remove the old key and restart: the old token is 401.
  10. On Prima with PRIMA_JWKS_URL pointing at the Labs API host: curl -s -H "X-API-Key: $KEY" $PRIMA/api/prima/v1/whoami reports "jwks_keys": 1 (or the number published) and a jwks_age_seconds under 300 after the first refresh; stop Labs and wait two intervals: jwks_keys is unchanged and jwks_age_seconds grows (the set never expires); the cache file at PRIMA_JWKS_CACHE_PATH has mode 600 and parses as {"fetched_at": ..., "document": {...}}.
  11. On Prima with PRIMA_JWKS_URL pointing at the Labs SPA host (the trap): the log shows jwks_refresh_rejected every interval and whoami reports "jwks_keys": 0 from a cold start. This check exists to be run once and then never again.
  12. python -m core.jwt_keys --generate prints a # kid: line, a PKCS#8 private PEM and a SubjectPublicKeyInfo public PEM; JWT_SIGNING_KEY_SOURCE=env JWT_ED25519_PRIVATE_KEY="$(printf '%s' "<the private PEM with newlines as literal \n>")" python -c "from core.jwt_keys import get_key_set; print(get_key_set().active.kid)" prints the same kid.

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.