Prima Get an API key

Module kit / Identity and auth

WebAuthn passkeys, multi relying party

identity-auth/webauthn-passkeys-multi-rp

Kind backend
Domain identity-auth
Maturity prod
Reusable extract
Stack fastapi, py-webauthn, sqlalchemy-sync, postgres
Depends on _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/test-layout
Frontends identity-auth/ui/auth-screens, identity-auth/ui/profile-and-passkeys
Reference implementation
  • alchemy_labs/backend/api/routers/webauthn_router.py
  • alchemy_labs/backend/services/webauthn_service.py
  • alchemy_labs/backend/models/user_model.py
  • alchemy_labs/backend/core/surfaces.py
  • alchemy_labs/backend/tests/test_webauthn_multi_rp.py
  • alchemy_labs/backend/tests/test_surface_rp_id.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • alchemycloud main b3d34da 2026-09-12
  • philosophers_stone main 6322fa3 2026-09-12
Version 1

Purpose

A person enrols a passkey on the surface they are using and later signs in on that surface with a touch, no address typed when the authenticator offers a discoverable credential, and gets a session on exactly that surface. The invariant that makes it trustworthy is that every credential row carries the relying-party id it was enrolled against, written once and never updated, and every lookup on the authentication path filters by the requesting surface's rp_id: a Labs passkey is never offered on a Prima origin, is refused if replayed there, and the RP id of an enrolled credential is never shortened, because the key material on the authenticator is bound to it and a change destroys the credential rather than migrating it.

This recipe fixes the two relying parties (labs.alchemy.zuerich for labs; prima.li for prima_web, prima_chat and prima_dev, one RP covering the registrable suffix), the ten routes, the two tables, the surface resolution order for a ceremony, the challenge namespacing, and the backfill that kept every pre-existing credential working.

When to use

Trigger phrases: "add a passkey", "sign in with Face ID", "the passkey button does nothing on Prima", "500 on register/begin", "the RP id", "can I shorten the domain", "a passkey enrolled on staging", "challenge expired", "cross-origin passkey from prima.li". The surface registry (identity-auth/surface-registry-and-gate, B4) is where the RP ids live; this recipe reads them and never defines a new one.

Do not use it for the session after a successful assertion (identity-auth/session-login-refresh-logout, B2 and S5: _create_passkey_session ends in create_session), for Aurum's Fastify passkey door (identity-auth/fastify-passkey-auth-ssh-enrolment, B22, a different stack with one logical user), for the clinic's WebAuthn which answers 501 (B23), or for the login-page choreography that decides when to try a discoverable login automatically (F1).

Stack assumptions

Pinned as measured at fd8558f: webauthn>=2.7.0 in backend/requirements.txt line 154 (a floor, not a pin; the venv resolves 2.7.0, which is what the tests ran against), cryptography==46.0.0 underneath it, fastapi==0.118.2, SQLAlchemy==2.0.43, cbor2 for the tests' software authenticator. Browser side: @simplewebauthn/browser ^13.2.2 (frontend/package.json 13) for startRegistration and startAuthentication. Foundations assumed: S4 (mounted mount_router(app, webauthn_router.router, prefix="/api/auth/webauthn", surfaces=ALL_SURFACES), app_fastapi.py 822), S5 (CurrentUser on the authenticated routes, create_session for the login), S7, S9. CORS is seeded from all_surface_origins() (app_fastapi.py 407 to 409), so a Prima origin can call the Labs API host with credentials.

Environment names on this path (core/config.py):

NameDefaultBehaviour
WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGINlocalhost, Alchemy Labs, http://localhost:3000read by nothing in the application at fd8558f; only the rp_id migration (20260803_0001_webauthn_rp_id.py 75 to 81) and the tests read them. The registry is a literal (Gotchas)
PRIMA_WEBAUTHN_RP_ID, PRIMA_WEBAUTHN_RP_NAME, PRIMA_COOKIE_DOMAINprima.li, Prima, .prima.lilikewise unread by the application
PRIMA_EXTRA_ORIGINSemptycomma-separated or JSON list of extra origins accepted for the Prima surfaces (core/surfaces.py 300 to 312) and added to CORS; the way a *.pages.dev preview or a localhost Prima build completes a ceremony
FRONTEND_URLhttp://localhost:3000added to the Labs surface's origins (core/surfaces.py 117 to 118), so a non-canonical Labs deployment passes expected_origin; the RP id stays the literal, which is why it does not help on localhost (Gotchas)
WEBAUTHN_RP_ID_BACKFILLunsetmigration only: the value stamped on every pre-existing credential row; a placeholder is refused unless set explicitly (S8, S13)
ENVIRONMENTdevelopmentSecure on the cookies in production

webauthn is imported at module load (webauthn_service.py 41 to 58); a missing package fails boot, which is the right direction for an auth module.

Data model

Two tables, owned here; columns as declared in models/user_model.py 270 to 317, DDL from migrations/versions/20260301_add_webauthn.py (19 to 32), 20260413_add_webauthn_challenges.py (18 to 29) and 20260803_0001_webauthn_rp_id.py:

webauthn_credentials (
  id            uuid PRIMARY KEY,
  user_id       uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id text NOT NULL UNIQUE,                 -- base64url, no padding, of the authenticator's credential id
  rp_id         varchar(255) NOT NULL,                -- the relying party enrolled against; IMMUTABLE after insert
  public_key    bytea NOT NULL,                       -- COSE key as py_webauthn returns it
  sign_count    integer NOT NULL DEFAULT 0,           -- as the authenticator reports it; py_webauthn refuses a non-increasing counter only when the stored or the presented value is non-zero, and platform passkeys report 0 forever (Gotchas)
  device_name   varchar(100) NOT NULL DEFAULT 'Passkey',
  transports    text NULL,                            -- JSON array, e.g. ["internal","hybrid"], or NULL
  created_at    timestamp DEFAULT now(),
  last_used_at  timestamp NULL
);
CREATE INDEX idx_webauthn_user ON webauthn_credentials(user_id);
CREATE INDEX idx_webauthn_cred_id ON webauthn_credentials(credential_id);
CREATE INDEX idx_webauthn_user_rp ON webauthn_credentials(user_id, rp_id);   -- webauthn_rp_id_001

webauthn_challenges (                                  -- ephemeral, cross-worker; rows live CHALLENGE_TTL_SECONDS = 300
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  challenge_key varchar(255) NOT NULL,                -- "reg:<surface>:<user_id>", "auth:<surface>:<email>", "disc:<surface>:<uuid4>"
  challenge     bytea NOT NULL,
  user_id       varchar(64) NULL,                     -- a string, not a uuid column
  email         varchar(255) NULL,
  created_at    timestamp DEFAULT now(),
  expires_at    timestamp NOT NULL
);
CREATE INDEX idx_webauthn_challenge_key ON webauthn_challenges(challenge_key);
CREATE INDEX idx_webauthn_challenge_expires ON webauthn_challenges(expires_at);

rp_id was added nullable, backfilled with one value for every existing row (the Labs RP in production), then set NOT NULL (webauthn_rp_id_001, "additive, backfilled, reversible"). No CHECK ties rp_id to the registry; the immutability is a code rule (no UPDATE of the column anywhere: grep -n "rp_id" services/webauthn_service.py shows reads and one insert). The challenge store deletes any row with the same key before inserting (_store_challenge, 95 to 115) and opportunistically deletes expired rows on every store, so the table stays small without a job.

What is not stored: the attestation statement (py_webauthn's verify_registration_response result is reduced to credential_id, credential_public_key and sign_count, 231 to 245), the AAGUID, the origin of enrolment (only the RP), the user-verification flag, and any per-surface list of allowed devices. The wire shapes come from webauthn.helpers.options_to_json unchanged, plus a surface key the router adds and, for the discoverable begin, a challenge_id.

API surface

All ten routes on webauthn_router at /api/auth/webauthn, mounted on ALL_SURFACES. The surface for a ceremony is resolve_surface(request, explicit) (api/dependencies/surface.py 57 to 89): an explicit id from the body surface field, the X-Alchemy-Surface header or ?surface=, which must be issuable (400 Unknown surface. Expected one of: labs, prima_web, prima_chat, prima_dev or That surface is external; Labs does not issue sessions for it.) and must agree with a recognised Origin (400 Requested surface does not match the request origin.); otherwise the Origin header, then the Referer host; otherwise labs.

RouteAuth depTenancyCodesError codes and bodies
POST /register/beginCurrentUsernone200 PublicKeyCredentialCreationOptions JSON plus surface400 surface conflict; 401 uniform; 500 with str(e) of any other exception (183; Gotchas)
POST /register/completeCurrentUsernone200 WebAuthnCredentialResponse {id, device_name, rp_id, created_at, last_used_at}400 Registration challenge expired or not found. Please try again., the service's own ValueError and the only 400 on this route; 500 Registration failed. Please try again. for every py_webauthn verification failure too, because none of its exceptions is a ValueError and the router's except ValueError (204) never sees them (Gotchas)
POST /login/beginnone; auth_rate_limitnone200 PublicKeyCredentialRequestOptions plus surface400 No account found with that email., No passkeys registered for this account. (Security: an oracle, rate-limited); 429; 500 Authentication failed. Please try again.
POST /login/begin-discoverablenone; auth_rate_limitnone200 options with empty allowCredentials, plus challenge_id and surface429; 500
POST /login/completenone; auth_rate_limitnone200 {access_token, refresh_token, token_type, expires_in, surface, user} and the two cookies (_create_passkey_session, 128 to 160)400 Authentication challenge expired or not found. Please try again., Passkey not recognized., Passkey does not match this account., Account is disabled. (the service's four ValueError strings, the only 400s here); 403 This account is not permitted on this surface. (SurfaceAccessDenied); 429; 500 Authentication failed. Please try again. for every py_webauthn failure, a wrong signature, a wrong origin, a non-increasing counter and a malformed credential alike (Gotchas)
POST /login/complete-discoverablenone; auth_rate_limitnone200, same body400 as above plus User not found., service strings only; 403; 429; 500 as above, including every py_webauthn failure
GET /credentials?surface=CurrentUserthe caller's own rows200 list of WebAuthnCredentialResponse, every RP when surface is omitted400 unknown surface; 401
DELETE /credentials/{id}CurrentUserown rows204404 Passkey not found (another user's id is a 404, not a 403)
PATCH /credentials/{id}CurrentUserown rows200 the renamed credential404; 422 for an empty or over-100-character device_name
GET /check?email=none; auth_rate_limitnone200 {has_passkeys, surface, rp_id}429

Request bodies (61 to 111, 268 to 272): every begin and complete carries an optional surface; register/complete {credential, device_name?, surface?}; login/begin {email}; login/complete {email, credential, surface?}; login/complete-discoverable {credential, challenge_id?, surface?}; PATCH {device_name}. The credential is the JSON @simplewebauthn/browser returns, passed to py_webauthn's parsers unchanged.

The cookies on the two login completes come from core/session_cookies.py::set_auth_cookies(response, a, r, surface=surface, request=request) (64 to 113): same-site with the surface's cookie_domain, SameSite=Lax, Secure in production; cross-site (a Prima origin calling the Labs API host) with no Domain, SameSite=None; Secure, and the tokens in the body because the browser may drop the cookie.

Application logic

Registration (webauthn_service.py 157 to 257):

  1. register_begin: resolve the surface; _as_surface refuses an external one (71 to 82). Select the caller's credentials where rp_id == sf.rp_id and pass them as exclude_credentials, so a Labs passkey is invisible to prima.li and cannot be "already registered" there. generate_registration_options(rp_id=sf.rp_id, rp_name=sf.rp_name, user_id=str(user.id).encode(), user_name=user.email, user_display_name=..., authenticator_selection=None, supported_pub_key_algs=[ES256, RS256]). Store the challenge under reg:<surface>:<user_id> with a 300 s expiry. Return options_to_json plus surface.
  2. register_complete: _consume_surface_challenge("reg", sf, user.id) (140 to 151: the namespaced key, then for labs only the pre-namespace key reg:<user_id> as a rolling-deploy fallback). verify_registration_response(credential, expected_challenge, expected_rp_id=sf.rp_id, expected_origin=list(sf.origins)). Parse transports from the response when present. Insert the row with rp_id = sf.rp_id, sign_count from the verification, device_name from the body. Log at INFO with user, rp_id and surface. Return the row.

Authentication with an address (259 to 301, 387 to 446):

  1. login_begin: load the user by lowercased email (ValueError when missing); select credentials where rp_id == sf.rp_id (ValueError when none); generate_authentication_options(rp_id=sf.rp_id, allow_credentials=[...], user_verification=PREFERRED); store the challenge under auth:<surface>:<email>; return options plus surface.
  2. login_complete: consume auth:<surface>:<email>; parse the assertion; select the credential by (credential_id, rp_id); the user must exist, match the email and be active; verify_authentication_response(credential, expected_challenge, expected_rp_id, expected_origin=list(sf.origins), credential_public_key, credential_current_sign_count); write sign_count = new_sign_count, last_used_at, user.last_login; commit; return the user. The router then mints through _create_passkey_session (128 to 160): create_session(user_id, ip, ua, surface=surface.id), cookies, body.

Discoverable authentication (303 to 385):

  1. login_begin_discoverable: options with empty allow_credentials, a fresh uuid4 as challenge_id, the challenge stored under disc:<surface>:<challenge_id> so concurrent attempts do not overwrite each other; the response carries challenge_id and surface.
  2. login_complete_discoverable: consume disc:<surface>:<challenge_id>; find the credential by (raw_id, rp_id); the user must exist and be active; verify as in 4; write the counter back; mint as in 4.

Management (448 to 513): list_credentials(user_id, surface=None) returns every RP when no surface is given, ordered newest first; delete_credential and rename_credential filter by (id, user_id) and answer False for another user's row; user_has_passkeys(email, surface) answers the /check oracle per RP.

Side-effect ordering worth keeping: the challenge is consumed (deleted) before verification, so a failed assertion cannot be retried against the same challenge; the credential row is written only after verify_registration_response returned; the session is minted only after the counter was written and committed.

Invariants

  • Every credential row has an rp_id and it is written once: no code path updates the column (grep -n "rp_id" services/webauthn_service.py shows the insert at 236 and reads only), and the migration's docstring names the reason (the private key is bound to it).
  • Every authentication-path lookup filters by the requesting surface's rp_id: begin_authentication (271 to 276), complete_authentication (402 to 407), complete_discoverable_authentication (348 to 353), user_has_passkeys (505 to 508). test_labs_credential_is_rejected_on_a_prima_surface (309), test_prima_credential_is_rejected_on_labs (330).
  • One RP covers the three Prima surfaces: a credential enrolled on prima_web authenticates on prima_chat and prima_dev (test_prima_credential_authenticates_on_every_prima_surface, 363) because all three declare webauthn_rp_id="prima.li" (core/surfaces.py 140 to 158); exactly two RPs exist (webauthn_rp_ids(), 342 to 350; test_labs_and_prima_are_two_relying_parties, 246).
  • The Labs RP id is labs.alchemy.zuerich and is never shortened (test_labs_rp_id_is_never_shortened, 254; test_labs_rp_id_is_the_enrolled_domain, test_surface_rp_id.py 39).
  • Challenges are namespaced by surface (reg:, auth:, disc: plus <surface>:), so a challenge minted for one surface cannot complete on another (test_challenges_are_namespaced_by_surface, 400; test_discoverable_login_is_bound_to_its_surface, 416); a challenge is single-use and lives 300 s.
  • The expected origin is the surface's origins tuple, canonical first (test_origins_starts_with_the_canonical_origin, test_surface_rp_id.py 116); an assertion signed for another origin is refused (test_assertion_from_the_wrong_origin_is_rejected, 384).
  • A surface claim that contradicts the Origin header is a 400, never a silent override (test_surface_resolution_rejects_a_claim_that_contradicts_the_origin, 670); no claim and no origin is labs (654).
  • An external surface (primapvs) is refused before any database read (_as_surface; test_service_refuses_an_external_surface, 483).
  • A passkey login is a session on the surface the ceremony resolved to, minted through create_session, which re-checks the grant: no grant is a 403, not a 500 (_create_passkey_session 128 to 145).
  • Surface.rp_id, rp_name and origins are readable on every registered surface without raising (test_surface_rp_id.py, parametrised over SURFACES); the recipe's Gotchas record the week they were not.

Frontend

The frontend modules that render this recipe, by id, and what each shows of it:

identity-auth/ui/auth-screens              LoginAlchemy: on load, when localStorage preferPasskey is "true", posts
                                           /login/begin-discoverable, runs startAuthentication, posts
                                           /login/complete-discoverable and stores the body tokens (frontend/src/
                                           pages/LoginAlchemy.tsx 52 to 89); the passkey button does the same on
                                           click (102 to 137); no surface is sent, so Origin then labs decides;
                                           the auto path returns silently on any non-2xx (63, 75); the button
                                           path reads err.detail (121), which the Labs envelope does not carry
                                           (it puts the string in `error`, app_fastapi.py 773 to 787), so every
                                           400 and the 403 render as "Passkey authentication failed"
identity-auth/ui/profile-and-passkeys      ProfileWithUsage: GET /credentials on load with no surface, rendered
                                           as one flat list of device_name, created_at and last_used_at (the
                                           PasskeyCredential type at 22 to 27 omits the rp_id the route returns);
                                           enrol = POST /register/begin -> startRegistration -> POST
                                           /register/complete with device_name "Passkey", DELETE and PATCH per
                                           row (pages/settings/ProfileWithUsage.tsx 118 to 214, the list 460
                                           to 528); no grouping by relying party and no origin note (Gotchas)

The Prima surfaces have no passkey UI at fd8558f: prima-chat and the developer portal sign in with magic links only (B5), and prima.li's own login is planned. The multi-RP backend is therefore exercised in production by Labs alone, with the Prima RP covered by tests.

Minimum admin and debug surface: the three INFO lines on success (WebAuthn credential registered for user <id> on rp_id=<rp> (surface=<s>): <device_name> at webauthn_service.py 249, WebAuthn discoverable authentication successful ... at 381, WebAuthn authentication successful ... at 438) and the ERROR lines from the router's except Exception branches; GET /credentials for the person themself; SELECT rp_id, count(*) FROM webauthn_credentials GROUP BY 1 as the operator's view of which RP the estate's passkeys are bound to. There is no admin route over another user's credentials.

Security & tenancy

Auth dependency: CurrentUser on registration and management, none on the login and check routes, which are rate-limited (auth_rate_limit, 30 per minute per client IP keyed by path). Workspace derivation: none; a credential belongs to a user, and the session that follows carries tid only when unambiguous (S5). Fail direction: an unknown or external surface is closed (400); a surface that contradicts the origin is closed; a missing challenge is closed; a grant missing at mint is closed (403); the rate limiter is open when Redis is down; the router's catch-all except Exception on register_begin is open in the sense that it returns the exception text (Gotchas).

Credentials at rest: public_key is a public key; credential_id is public by the standard's design; nothing on these rows is a bearer credential, which is why rp_id is the security-relevant column; the sign counter is security-relevant only on an authenticator that keeps one, which a platform passkey does not (Gotchas). A stolen webauthn_credentials table lets an attacker enumerate who has a passkey and where, nothing more.

Attacker-controlled inputs: credential JSON (parsed by py_webauthn; a malformed shape is an InvalidJSONStructure, which is not a ValueError, so it renders as 500 with the fixed sentence rather than a 400, Gotchas); challenge_id (a uuid string used as a key suffix; unknown is a 400); surface (validated and cross-checked with Origin); Origin and Referer (used only to pick the surface, then expected_origin is the registry's list, never the header); email on /login/begin and /check (both are oracles by design: begin distinguishes "no account" from "no passkeys" in its 400 text, check answers has_passkeys per RP, and the rate limiter is the only brake); device_name (bounded to 100 characters, stored verbatim, rendered by the profile page, which escapes it as React does); credential_id in a DELETE or PATCH path (filtered by the caller's user_id, so another user's id is a 404).

Tenancy of a credential: (user_id, rp_id); a person with a Labs passkey and a Prima passkey holds two rows that never meet on the authentication path. Cross-origin: a Prima origin calling the Labs API host is a cross-site request; CORS admits it because the registry seeds ALLOWED_ORIGINS, the cookies are SameSite=None; Secure and the caller uses the body tokens (test_cross_origin_preflight_from_prima_is_allowed, 706; test_rate_limit_still_bites_a_cross_origin_caller, 769, currently red for a stale reason). The passkey login path does not consult is_verified; a passkey can only be enrolled from an authenticated session, which an unverified account cannot obtain while REQUIRE_EMAIL_VERIFICATION is on (B1), so the gap is closed upstream rather than here.

Tests

Backend (alchemy_labs/backend/tests/, run 2026-09-12 at fd8558f against the :5434 test Postgres with python -m pytest <file> -q --no-cov -o addopts="" -W ignore):

  • test_surface_rp_id.py: 40 passed in 0.22s. Parametrised over every surface: rp_id == webauthn_rp_id, readable without raising, rp_name non-empty and shared across the three Prima surfaces, origins a tuple of unique strings without trailing slash starting with the canonical origin, the Labs RP pinned to labs.alchemy.zuerich.
  • test_webauthn_multi_rp.py: 26 passed, 2 failed in 5.25s. The suite builds a scratch database of three tables on the test Postgres (webauthn_multirp_scratch, 43 to 48) and a SoftAuthenticator (79 to 240): one ES256 key per RP, a CBOR COSE public key, authenticator data with the SHA-256 of the RP id, a real signature over authenticatorData || sha256(clientDataJSON). That is the known-answer vector: test_existing_labs_credential_still_authenticates (283) stores a row shaped like the backfill wrote it, runs begin_authentication and complete_authentication through the real service with no mocking of verify_authentication_response, and asserts rp_id untouched, sign_count == 1 and last_used_at set. Then the cross-RP refusals (309, 330), one user on both RPs (340), one Prima credential on every Prima surface (363), the wrong origin (384), the namespaced challenges (400, 416), registration binding the RP (435), user_has_passkeys per RP (472), the external surface (483). The migration pair (598, 610) creates a second scratch database with the pre-change table, runs webauthn_rp_id_001 with WEBAUTHN_RP_ID_BACKFILL set, and authenticates a credential enrolled before the change. Surface resolution (654, 670, 680), CORS (690, 706), the no-body Labs call (742), the rate limiter on every login route (793), the link base (847).
    • test_rate_limit_still_bites_a_cross_origin_caller (769) fails with assert 422 == 429: it expects the eleventh request to be refused, and the per-IP ceiling has been 30 since e895e7d (2026-09-05, rate_limit.py 26). The limiter works; the test's number is stale. Fix: send 31 or read MAX_REQUESTS.
    • test_prima_surfaces_get_cross_site_cookie_treatment (820) fails on its first assertion: it expects a Labs same-site cookie to carry no Domain, and set_auth_cookies sets domain = surface.cookie_domain on the same-site branch (session_cookies.py 79 to 82), which for Labs is the Labs host. The cross-site half of the test is not reached. Fix: decide whether the same-site branch sets Domain (the code) or not (the test and auth_router._set_auth_cookies), then align the other two.

Hermetic versus live: everything is hermetic; the scratch databases are created and dropped by the module. Playwright: none for a passkey (a browser test needs a virtual authenticator; frontend/e2e/auth.spec.ts has no passkey case at fd8558f).

Known-answer vectors a rebuilt service must reproduce: the rpId in the begin options equal to the surface's RP id; a base64url credential_id without padding round-tripping through _b64url_encode and _b64url_decode (525 to 534); on a counter-bearing authenticator (the suite's SoftAuthenticator, which increments at test_webauthn_multi_rp.py 106), sign_count advancing by one per assertion and a replayed assertion with the same counter refused, py_webauthn applying that refusal only when the stored or the presented count is non-zero (webauthn/authentication/verify_authentication_response.py 148 to 155 in the resolved 2.7.0), so a platform passkey that reports 0 verifies and leaves the column at 0; the challenge keys reg:<surface>:<user_id>, auth:<surface>:<email>, disc:<surface>:<uuid>; the four 400 strings of the login path.

Deploy & ops

Config keys per app heredoc (S13, alchemycloud b3d34da): the labs-prod heredoc (215 to 360) carries WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN; the labs-staging heredoc (362 to 550) carries neither (the EOF markers are at 360 and 550; S13 still cites 361 and 551). At fd8558f both facts change only the migration's backfill (which reads WEBAUTHN_RP_ID before refusing a placeholder), not the running relying party, which is the registry literal on every host. PRIMA_EXTRA_ORIGINS is the key to set when a Prima preview host must complete a ceremony; FRONTEND_URL is what admits a non-canonical Labs origin. REDIS_URL for the limiter.

Migrations order: 20260301_add_webauthn (the credentials table), 20260413_add_webauthn_challenges, then webauthn_rp_id_001 (20260803_0001_webauthn_rp_id.py), which needs WEBAUTHN_RP_ID_BACKFILL in the migration job's environment on any database whose inferred value would be a placeholder; a fresh database with no credential rows still needs the variable because the refusal happens before the row count is known (_backfill_rp_id, 70 to 110). The harness sets WEBAUTHN_RP_ID_BACKFILL=localhost for that reason (AUTHORING.md, the Labs section; tests/sparks/conftest.py 12).

Scheduler jobs: none; challenge cleanup is opportunistic at store time (109 to 113). Argus checks: GET /api/auth/webauthn/[email protected] answers 200 {"has_passkeys": false, "surface": "labs", "rp_id": "labs.alchemy.zuerich"} from the Labs host, and the same from a Prima origin (with Origin set) answers "rp_id": "prima.li"; POST /login/begin-discoverable answers 200 with an rpId. Those two are what would have caught the Surface attribute saga a week earlier.

Rollback: reverting webauthn_rp_id_001 drops the column and the service at fd8558f fails at runtime on the first query naming it (models/user_model.py 285 declares the column, so import succeeds; the first select inside get_db_context(), webauthn_service.py 157 onward, raises UndefinedColumn), so a rollback of the code must precede a downgrade of the schema (S8's order). The credential rows are otherwise stable across releases; a passkey enrolled on the new image verifies on the old one as long as the RP id literal is unchanged.

Gotchas

  • 2026-09-12 (measured at fd8558f): the six WEBAUTHN_* and PRIMA_WEBAUTHN_* settings are read by nothing in the application. core/config.py 134 to 137 says they are "read by core.surfaces to build the labs Surface"; core/surfaces.py::SURFACES (125 to 166) is a module-level literal, reload_registry() (367 to 371) is a documented no-op, and grep -rn "WEBAUTHN_RP_ID\|PRIMA_WEBAUTHN" backend --include='*.py' outside core/config.py hits only the migration and the tests. Consequences: (a) the test fixture that sets settings.WEBAUTHN_RP_ID and calls reload_registry() (test_webauthn_multi_rp.py 55 to 67) changes nothing, and test_labs_rp_id_is_never_shortened (254) compares the literal with a value the fixture just set to the same string; (b) a Labs developer on localhost cannot run a ceremony at all, because the RP id offered to the browser is labs.alchemy.zuerich and a browser refuses an RP id that is not a registrable suffix of the page's origin, FRONTEND_URL in origins notwithstanding; (c) the S13 request to add WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to the staging heredoc affects the backfill only. Fix: build SURFACES from the settings (the comment's claim) with the production values as defaults, or delete the keys and the comment; either way reload_registry becomes real or is deleted.
  • 2026-09-12 (measured at fd8558f): two tests in test_webauthn_multi_rp.py are red for stale reasons (Tests): the rate-limit count (10 versus 30 since 2026-09-05) and the same-site Domain disagreement between core/session_cookies.py (sets it) and both the test and auth_router._set_auth_cookies (do not). The second is not cosmetic: a passkey login and a magic-link login on the same browser leave two access_token cookies, one host-only and one with Domain, and _clear_auth_cookies (auth_router.py 82 to 85) deletes the host-only one, so a passkey session's cookie survives logout for its lifetime. The SPA uses body tokens, so nothing reads that cookie today; a cookie-mode client would.
  • 2026-09-12 (measured at fd8558f): register_begin answers HTTPException(500, detail=str(e)) on any unexpected exception (webauthn_router.py 183). S7 says no str(exc) reaches a client; the three other 500 branches in this file use a fixed sentence. This is the branch that surfaced 'Surface' object has no attribute 'rp_name' to a browser on 2026-09-04. Fix: the fixed sentence.
  • 2026-09-12 (measured at fd8558f against the resolved webauthn 2.7.0; D8): every py_webauthn verification failure answers 500, not 400. The router catches ValueError before Exception on register/complete (webauthn_router.py 204 to 208) and on the two login completes (261 to 265, 285 to 289), but none of the library's verification exceptions is a ValueError: InvalidRegistrationResponse (9), InvalidAuthenticationResponse (17), InvalidJSONStructure (29) and SignatureVerificationException (37) all derive from WebAuthnException(Exception) (webauthn/helpers/exceptions.py 1; in the image's environment issubclass(InvalidAuthenticationResponse, ValueError) is False and WebAuthnException.__mro__ is (WebAuthnException, Exception, BaseException, object)), and the service calls verify_registration_response and verify_authentication_response unwrapped (webauthn_service.py 217 to 222, 367 to 374, 423 to 430). A wrong signature, an assertion signed for another origin, a non-increasing sign counter and a malformed credential dict therefore fall through to except Exception and reach the caller as 500 Authentication failed. Please try again. or Registration failed. Please try again., indistinguishable from a server fault both to the browser and to 500-based alerting. Only the service's own ValueErrors are 400: the two challenge sentences and the four login strings. The reference's own test_assertion_from_the_wrong_origin_is_rejected (test_webauthn_multi_rp.py 384 to 396) asserts pytest.raises(Exception), which is why the suite is green on it. Fix (D8, recorded, not applied in this wave): except WebAuthnException above except Exception on those three routes, mapped to 400 with a fixed non-reflective sentence (S7 forbids str(exc) here, and the library's text quotes the attacker's origin back), leaving except Exception for genuine 500s.
  • 2026-09-12 (measured against the resolved webauthn 2.7.0; D8): the sign counter is not a replay guard on the devices the estate actually uses. py_webauthn refuses a non-increasing counter only when one side is non-zero (webauthn/authentication/verify_authentication_response.py 148 to 155: if (auth_data.sign_count > 0 or credential_current_sign_count > 0) and auth_data.sign_count <= credential_current_sign_count), and platform passkeys (Touch ID, Face ID, iCloud Keychain) report signCount 0 for the life of the credential, so the stored value stays 0 (webauthn_service.py 376 and 433 write verification.new_sign_count straight back) and the refusal never fires. A counter-bearing security key is the only case where it does. The suite's SoftAuthenticator increments its own counter (test_webauthn_multi_rp.py 106), which is why test_existing_labs_credential_still_authenticates can assert sign_count == 1 (305); nothing at fd8558f exercises a zero-counter authenticator. This is the standard's design rather than a fault in the reference, but any reading that rests replay protection on the counter is wrong here: the single-use challenge, consumed before verification, is what stops a replay, and last_used_at (377 and 434) is the only per-assertion trace. Fix (recorded): say so wherever the counter is described, and add a test with a zero-counter software authenticator so the behaviour is pinned rather than assumed.
  • 2026-09-12 (measured at fd8558f): _consume_surface_challenge still falls back to the un-namespaced reg:<user_id>, auth:<email> and disc:<challenge_id> keys for labs (140 to 151: f"{kind}:{ident}" for every kind) with the note "this fallback can be deleted one deploy later"; the code was merged 2026-08-03 (9030301) and first served in production at the 2026-08-29 cutover (core/surfaces.py 63 to 66: the first passkey attempt after the cutover was the first against this code). Harmless (a five-minute window against a key shape nothing writes any more), and dead code in an auth module. Fix: delete the fallback.
  • 2026-09-12 (measured at fd8558f): the router's docstring on GET /credentials (webauthn_router.py 300 to 302) says "the management UI groups them by rp_id so a person enrolled on both Labs and Prima can see which is which"; no UI does. ProfileWithUsage.tsx maps the list flat (468 to 525), its PasskeyCredential type has no rp_id (22 to 27), and grep -rn -i 'rp_id\|relying\|origin-bound' frontend/src hits nothing. Until 2026-09-12 this recipe's Frontend section repeated the docstring. The grouping and an origin note (a Labs passkey will not be offered on a Prima origin) are the F5 rebuild's additions, recorded there (identity-auth/ui/profile-and-passkeys.md, Gotchas, the B2 and B7 entry); the backend already returns rp_id on every row (_credential_response, 114 to 121), so the rebuild needs no route change.
  • 2026-09-12 (measured at fd8558f): POST /login/begin distinguishes "no account" from "no passkeys" in its 400 text (begin_authentication 268 to 280), and GET /check is an oracle by design (the docstring at 341 to 346 says why: never offer a button that opens an empty picker). Both are behind the 30-per-minute limiter and behind nothing else. A rebuilt service that wants no oracle answers begin with a fake challenge for unknown addresses and drops /check in favour of the discoverable flow, which needs no address.
  • 2026-09-12 (measured at fd8558f): webauthn>=2.7.0 is a floor in requirements.txt (line 154) among otherwise exact pins; a rebuild of the image after a py_webauthn major release changes the verifier under the tests. Fix: webauthn==2.7.0.
  • 2026-09-04 (20d2e46 and e803698; the saga recorded in core/surfaces.py 53 to 122, tests/test_surface_rp_id.py header and wiki platform/architecture.md): webauthn_service.py reads sf.rp_id, sf.rp_name and sf.origins; the dataclass declared webauthn_rp_id, origin, hosts, cookie_domain, external and none of the three. Every ceremony on every surface raised AttributeError and answered 500 from the first passkey attempt after the 2026-08-29 cutover (login/begin-discoverable) until the third alias landed on 2026-09-04; login was fixed 2026-08-29, registration 500'd until 2026-09-04 on rp_name and then on origins. Each was found by a live 500, because the team signs in with magic links. The three are @property aliases now and test_surface_rp_id.py covers every surface. When a fix adds one attribute, grep every attribute the caller reads (S5 records the same lesson).
  • 2026-08-02 (78b2e1c, measured at fd8558f): the backfill refuses an inferred placeholder (_NOT_A_REAL_RP, migration 67) because settings.WEBAUTHN_RP_ID defaults to localhost and alembic upgrade head runs from laptops and CI where the variable is absent; a backfill with localhost would rewrite every enrolled credential to an RP no browser matches while reporting success. WEBAUTHN_RP_ID_BACKFILL is honoured as given, placeholder included, so a local database can say so on purpose.
  • 2026-08-02 (c7b9d88, measured at fd8558f): the RP id must not be shortened from labs.alchemy.zuerich to the apex; the comment at core/surfaces.py 129 to 130 and test_labs_rp_id_is_never_shortened exist because it looked like a harmless cleanup.

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 identical to feat/service-desk b1089b6):
    • backend/api/routers/webauthn_router.py: schemas (61 to 111), _credential_response (114 to 121), _create_passkey_session (128 to 160), register_begin (167 to 184), register_complete (186 to 208), login_begin (215 to 229), login_begin_discoverable (232 to 249), login_complete (252 to 265), WebAuthnDiscoverableCompleteRequest (268 to 271), login_complete_discoverable (274 to 289), list_credentials (296 to 306), delete_credential (309 to 314), rename_credential (317 to 328), check_passkeys (335 to 349); the mount at backend/app_fastapi.py 822, CORS seeding 399 to 409.
    • backend/services/webauthn_service.py: header (1 to 27), _as_surface (71 to 82), CHALLENGE_TTL_SECONDS (93), _store_challenge (95 to 115), _consume_challenge (117 to 138), _consume_surface_challenge (140 to 151), begin_registration (157 to 201), complete_registration (203 to 257), begin_authentication (259 to 301), begin_discoverable_authentication (303 to 327), complete_discoverable_authentication (329 to 385), complete_authentication (387 to 446), list_credentials (448 to 463), delete_credential (465 to 474), rename_credential (476 to 488), user_has_passkeys (490 to 513), helpers (516 to 534).
    • backend/api/dependencies/surface.py: SURFACE_HEADER (43), surface_from_origin_headers (46 to 54), resolve_surface (57 to 89), request_surface (92 to 94).
    • backend/core/surfaces.py: Surface with rp_id, rp_name, origins (42 to 122), SURFACES (125 to 166), resolve_issuable_surface (285 to 297), _extra_origins (300 to 312), surface_for_origin (315 to 324), webauthn_rp_ids (342 to 350), all_surface_origins (353 to 364), reload_registry (367 to 371).
    • backend/core/session_cookies.py: is_same_site (49 to 61), set_auth_cookies (64 to 113).
    • backend/models/user_model.py: WebAuthnCredential (270 to 299), WebAuthnChallenge (302 to 317).
    • backend/core/config.py: the WebAuthn block (132 to 155).
    • backend/migrations/versions/20260301_add_webauthn.py, 20260413_add_webauthn_challenges.py, 20260803_0001_webauthn_rp_id.py (_NOT_A_REAL_RP 67, _backfill_rp_id 70 to 110).
    • backend/api/dependencies/rate_limit.py: auth_rate_limit (38 to 60).
    • Tests: backend/tests/test_webauthn_multi_rp.py (the fixture 55 to 67, SoftAuthenticator 79 to 240, the cases as listed under Tests), backend/tests/test_surface_rp_id.py (28 to 122).
    • frontend/src/pages/LoginAlchemy.tsx (52 to 137), frontend/src/pages/settings/ProfileWithUsage.tsx (22 to 27, 118 to 214, 460 to 528), frontend/package.json 13.
  • The resolved dependency in the image's environment, webauthn 2.7.0 (a floor in requirements.txt line 154, Gotchas): webauthn/helpers/exceptions.py (WebAuthnException(Exception) 1, InvalidRegistrationResponse 9, InvalidAuthenticationResponse 17, InvalidJSONStructure 29, SignatureVerificationException 37), webauthn/authentication/verify_authentication_response.py 148 to 155 (the conditional sign-counter refusal).
  • alchemycloud (main, b3d34da): scripts/distribute-env.sh labs-prod (215 to 360, the two WEBAUTHN_* keys) and labs-staging (362 to 550, none), key names only.
  • Wiki philosophers_stone (6322fa3): platform/architecture.md ("Passkeys are multi-RP (2026-08-02)", "Passkey registration was broken on every surface for a week"), operations/prima-modules-inventory.md (row B7; S13's wave-1 finding on the staging heredoc).

Deviations of the reference from this recipe, all dated 2026-09-12: the registry is a literal and the six WebAuthn settings are dead for the application; the same-site Domain disagreement and its logout consequence; str(e) in one 500; every py_webauthn verification failure rendering as a 500 because none of its exceptions is a ValueError; the un-namespaced challenge fallback; the enumeration texts on /login/begin; the version floor on webauthn; two stale tests; no Prima surface renders the enrolment UI; the Labs profile page lists passkeys flat without rp_id or an origin note; the Labs login page reads err.detail from an envelope that carries error, so no backend sentence reaches a person on the passkey path.

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.

  1. python -m pytest tests/test_surface_rp_id.py -q --no-cov -o addopts="" -W ignore prints 40 passed; python -m pytest tests/test_webauthn_multi_rp.py -q --no-cov -o addopts="" -W ignore prints 26 passed, 2 failed with exactly test_rate_limit_still_bites_a_cross_origin_caller and test_prima_surfaces_get_cross_site_cookie_treatment failing (on a service built to this recipe: 28 passed).
  2. python -c "from core import surfaces as s; print(s.webauthn_rp_ids()); print([s.get_surface(i).rp_id for i in ('labs','prima_web','prima_chat','prima_dev')])" prints ('labs.alchemy.zuerich', 'prima.li') and ['labs.alchemy.zuerich', 'prima.li', 'prima.li', 'prima.li'].
  3. With the app running: curl -s 'http://127.0.0.1:8000/api/auth/webauthn/[email protected]' prints {"has_passkeys":false,"surface":"labs","rp_id":"labs.alchemy.zuerich"}; the same with -H 'Origin: https://chat.prima.li' prints "surface":"prima_chat","rp_id":"prima.li"; the same with -H 'Origin: https://chat.prima.li' -H 'X-Alchemy-Surface: labs' is 400 with Requested surface does not match the request origin.; ?surface=primapvs is 400 naming an external surface.
  4. curl -s -X POST http://127.0.0.1:8000/api/auth/webauthn/login/begin-discoverable -H 'Content-Type: application/json' -d '{}' prints options with "rpId":"labs.alchemy.zuerich", an empty allowCredentials, a challenge_id and "surface":"labs"; SELECT challenge_key FROM webauthn_challenges ORDER BY created_at DESC LIMIT 1 shows disc:labs:<uuid>; the same POST with -d '{"surface":"prima_web"}' shows "rpId":"prima.li" and a key disc:prima_web:<uuid>.
  5. With a session token $T for a user: curl -s -X POST http://127.0.0.1:8000/api/auth/webauthn/register/begin -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -d '{}' prints creation options with "rp":{"id":"labs.alchemy.zuerich","name":"Alchemy Labs"}, pubKeyCredParams listing -7 and -257, and "surface":"labs"; a second call replaces the row under reg:labs:<user_id> (SELECT count(*) FROM webauthn_challenges WHERE challenge_key = 'reg:labs:<user_id>' prints 1).
  6. Enrol through the profile page in a browser on the canonical host, then: SELECT rp_id, sign_count, device_name FROM webauthn_credentials WHERE user_id = :u shows labs.alchemy.zuerich | <n> | <name>, with <n> 0 on a platform authenticator (Touch ID, Face ID, iCloud Keychain) and whatever the key reports on a counter-bearing security key; sign in with the passkey once and the same query shows last_used_at set and sign_count unchanged or increased, exactly 1 only on a counter-bearing key (Gotchas); GET /api/auth/webauthn/credentials with the new session lists the row with its rp_id; GET ...?surface=prima_web lists nothing.
  7. curl -s -i -X DELETE http://127.0.0.1:8000/api/auth/webauthn/credentials/<another user's credential id> -H "Authorization: Bearer $T" shows 404 and Passkey not found; the same with the caller's own id shows 204.
  8. In backend: grep -c "UPDATE webauthn_credentials\|\.rp_id = " services/webauthn_service.py prints 0 (the column is written by the insert at line 236 only); grep -rn "WEBAUTHN_RP_ID\|PRIMA_WEBAUTHN" --include='*.py' . | grep -v "core/config.py\|migrations/\|tests/" | wc -l prints 0 (the deviation; on a service built to this recipe the registry reads the settings and the count is at least 1).
  9. python -c "import importlib.metadata as m; print(m.version('webauthn'))" in the image's environment prints 2.7.0; a different value means the floor moved and the suite in check 1 must be rerun before the deploy.
  10. In the same environment: python -c "from webauthn.helpers.exceptions import InvalidAuthenticationResponse as E, InvalidJSONStructure as J; print(issubclass(E, ValueError), issubclass(J, ValueError))" prints False False, which is why the router's except ValueError catches no verification failure and every one of them is a 500 at fd8558f (Gotchas); a service built to this recipe prints the same two values and catches WebAuthnException instead, so the same failures are 400. python -c "import inspect, webauthn.authentication.verify_authentication_response as v; print('sign_count > 0 or credential_current_sign_count > 0' in inspect.getsource(v))" prints True: the counter refusal is conditional, so a platform passkey reporting 0 verifies and leaves the column at 0 (check 6).

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.