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 |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGIN | localhost, Alchemy Labs, http://localhost:3000 | read 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_DOMAIN | prima.li, Prima, .prima.li | likewise unread by the application |
PRIMA_EXTRA_ORIGINS | empty | comma-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_URL | http://localhost:3000 | added 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_BACKFILL | unset | migration only: the value stamped on every pre-existing credential row; a placeholder is refused unless set explicitly (S8, S13) |
ENVIRONMENT | development | Secure 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.
| Route | Auth dep | Tenancy | Codes | Error codes and bodies |
|---|---|---|---|---|
POST /register/begin | CurrentUser | none | 200 PublicKeyCredentialCreationOptions JSON plus surface | 400 surface conflict; 401 uniform; 500 with str(e) of any other exception (183; Gotchas) |
POST /register/complete | CurrentUser | none | 200 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/begin | none; auth_rate_limit | none | 200 PublicKeyCredentialRequestOptions plus surface | 400 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-discoverable | none; auth_rate_limit | none | 200 options with empty allowCredentials, plus challenge_id and surface | 429; 500 |
POST /login/complete | none; auth_rate_limit | none | 200 {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-discoverable | none; auth_rate_limit | none | 200, same body | 400 as above plus User not found., service strings only; 403; 429; 500 as above, including every py_webauthn failure |
GET /credentials?surface= | CurrentUser | the caller's own rows | 200 list of WebAuthnCredentialResponse, every RP when surface is omitted | 400 unknown surface; 401 |
DELETE /credentials/{id} | CurrentUser | own rows | 204 | 404 Passkey not found (another user's id is a 404, not a 403) |
PATCH /credentials/{id} | CurrentUser | own rows | 200 the renamed credential | 404; 422 for an empty or over-100-character device_name |
GET /check?email= | none; auth_rate_limit | none | 200 {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):
register_begin: resolve the surface;_as_surfacerefuses an external one (71 to 82). Select the caller's credentials whererp_id == sf.rp_idand pass them asexclude_credentials, so a Labs passkey is invisible toprima.liand 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 underreg:<surface>:<user_id>with a 300 s expiry. Returnoptions_to_jsonplussurface.register_complete:_consume_surface_challenge("reg", sf, user.id)(140 to 151: the namespaced key, then forlabsonly the pre-namespace keyreg:<user_id>as a rolling-deploy fallback).verify_registration_response(credential, expected_challenge, expected_rp_id=sf.rp_id, expected_origin=list(sf.origins)). Parsetransportsfrom the response when present. Insert the row withrp_id = sf.rp_id,sign_countfrom the verification,device_namefrom the body. Log at INFO with user, rp_id and surface. Return the row.
Authentication with an address (259 to 301, 387 to 446):
login_begin: load the user by lowercased email (ValueErrorwhen missing); select credentials whererp_id == sf.rp_id(ValueErrorwhen none);generate_authentication_options(rp_id=sf.rp_id, allow_credentials=[...], user_verification=PREFERRED); store the challenge underauth:<surface>:<email>; return options plussurface.login_complete: consumeauth:<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); writesign_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):
login_begin_discoverable: options with emptyallow_credentials, a freshuuid4aschallenge_id, the challenge stored underdisc:<surface>:<challenge_id>so concurrent attempts do not overwrite each other; the response carrieschallenge_idandsurface.login_complete_discoverable: consumedisc:<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_idand it is written once: no code path updates the column (grep -n "rp_id" services/webauthn_service.pyshows 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_webauthenticates onprima_chatandprima_dev(test_prima_credential_authenticates_on_every_prima_surface, 363) because all three declarewebauthn_rp_id="prima.li"(core/surfaces.py140 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.zuerichand is never shortened (test_labs_rp_id_is_never_shortened, 254;test_labs_rp_id_is_the_enrolled_domain,test_surface_rp_id.py39). - 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
originstuple, canonical first (test_origins_starts_with_the_canonical_origin,test_surface_rp_id.py116); an assertion signed for another origin is refused (test_assertion_from_the_wrong_origin_is_rejected, 384). - A surface claim that contradicts the
Originheader is a 400, never a silent override (test_surface_resolution_rejects_a_claim_that_contradicts_the_origin, 670); no claim and no origin islabs(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_session128 to 145). Surface.rp_id,rp_nameandoriginsare readable on every registered surface without raising (test_surface_rp_id.py, parametrised overSURFACES); 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_namenon-empty and shared across the three Prima surfaces,originsa tuple of unique strings without trailing slash starting with the canonical origin, the Labs RP pinned tolabs.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 aSoftAuthenticator(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 overauthenticatorData || sha256(clientDataJSON). That is the known-answer vector:test_existing_labs_credential_still_authenticates(283) stores a row shaped like the backfill wrote it, runsbegin_authenticationandcomplete_authenticationthrough the real service with no mocking ofverify_authentication_response, and assertsrp_iduntouched,sign_count == 1andlast_used_atset. 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_passkeysper RP (472), the external surface (483). The migration pair (598, 610) creates a second scratch database with the pre-change table, runswebauthn_rp_id_001withWEBAUTHN_RP_ID_BACKFILLset, 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 withassert 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.py26). The limiter works; the test's number is stale. Fix: send 31 or readMAX_REQUESTS.test_prima_surfaces_get_cross_site_cookie_treatment(820) fails on its first assertion: it expects a Labs same-site cookie to carry noDomain, andset_auth_cookiessetsdomain = surface.cookie_domainon the same-site branch (session_cookies.py79 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 setsDomain(the code) or not (the test andauth_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_*andPRIMA_WEBAUTHN_*settings are read by nothing in the application.core/config.py134 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, andgrep -rn "WEBAUTHN_RP_ID\|PRIMA_WEBAUTHN" backend --include='*.py'outsidecore/config.pyhits only the migration and the tests. Consequences: (a) the test fixture that setssettings.WEBAUTHN_RP_IDand callsreload_registry()(test_webauthn_multi_rp.py55 to 67) changes nothing, andtest_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 onlocalhostcannot run a ceremony at all, because the RP id offered to the browser islabs.alchemy.zuerichand a browser refuses an RP id that is not a registrable suffix of the page's origin,FRONTEND_URLinoriginsnotwithstanding; (c) the S13 request to addWEBAUTHN_RP_IDandWEBAUTHN_ORIGINto the staging heredoc affects the backfill only. Fix: buildSURFACESfrom the settings (the comment's claim) with the production values as defaults, or delete the keys and the comment; either wayreload_registrybecomes real or is deleted. - 2026-09-12 (measured at fd8558f): two tests in
test_webauthn_multi_rp.pyare red for stale reasons (Tests): the rate-limit count (10 versus 30 since 2026-09-05) and the same-siteDomaindisagreement betweencore/session_cookies.py(sets it) and both the test andauth_router._set_auth_cookies(do not). The second is not cosmetic: a passkey login and a magic-link login on the same browser leave twoaccess_tokencookies, one host-only and one withDomain, and_clear_auth_cookies(auth_router.py82 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_beginanswersHTTPException(500, detail=str(e))on any unexpected exception (webauthn_router.py183). S7 says nostr(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
webauthn2.7.0; D8): every py_webauthn verification failure answers 500, not 400. The router catchesValueErrorbeforeExceptiononregister/complete(webauthn_router.py204 to 208) and on the two login completes (261 to 265, 285 to 289), but none of the library's verification exceptions is aValueError:InvalidRegistrationResponse(9),InvalidAuthenticationResponse(17),InvalidJSONStructure(29) andSignatureVerificationException(37) all derive fromWebAuthnException(Exception)(webauthn/helpers/exceptions.py1; in the image's environmentissubclass(InvalidAuthenticationResponse, ValueError)isFalseandWebAuthnException.__mro__is(WebAuthnException, Exception, BaseException, object)), and the service callsverify_registration_responseandverify_authentication_responseunwrapped (webauthn_service.py217 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 toexcept Exceptionand reach the caller as 500Authentication failed. Please try again.orRegistration failed. Please try again., indistinguishable from a server fault both to the browser and to 500-based alerting. Only the service's ownValueErrors are 400: the two challenge sentences and the four login strings. The reference's owntest_assertion_from_the_wrong_origin_is_rejected(test_webauthn_multi_rp.py384 to 396) assertspytest.raises(Exception), which is why the suite is green on it. Fix (D8, recorded, not applied in this wave):except WebAuthnExceptionaboveexcept Exceptionon those three routes, mapped to 400 with a fixed non-reflective sentence (S7 forbidsstr(exc)here, and the library's text quotes the attacker's origin back), leavingexcept Exceptionfor genuine 500s. - 2026-09-12 (measured against the resolved
webauthn2.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.py148 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) reportsignCount0 for the life of the credential, so the stored value stays 0 (webauthn_service.py376 and 433 writeverification.new_sign_countstraight back) and the refusal never fires. A counter-bearing security key is the only case where it does. The suite'sSoftAuthenticatorincrements its own counter (test_webauthn_multi_rp.py106), which is whytest_existing_labs_credential_still_authenticatescan assertsign_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, andlast_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_challengestill falls back to the un-namespacedreg:<user_id>,auth:<email>anddisc:<challenge_id>keys forlabs(140 to 151:f"{kind}:{ident}"for everykind) 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.py63 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.py300 to 302) says "the management UI groups them byrp_idso a person enrolled on both Labs and Prima can see which is which"; no UI does.ProfileWithUsage.tsxmaps the list flat (468 to 525), itsPasskeyCredentialtype has norp_id(22 to 27), andgrep -rn -i 'rp_id\|relying\|origin-bound' frontend/srchits 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 returnsrp_idon every row (_credential_response, 114 to 121), so the rebuild needs no route change. - 2026-09-12 (measured at fd8558f):
POST /login/begindistinguishes "no account" from "no passkeys" in its 400 text (begin_authentication268 to 280), andGET /checkis 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 answersbeginwith a fake challenge for unknown addresses and drops/checkin favour of the discoverable flow, which needs no address. - 2026-09-12 (measured at fd8558f):
webauthn>=2.7.0is a floor inrequirements.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.py53 to 122,tests/test_surface_rp_id.pyheader and wikiplatform/architecture.md):webauthn_service.pyreadssf.rp_id,sf.rp_nameandsf.origins; the dataclass declaredwebauthn_rp_id,origin,hosts,cookie_domain,externaland none of the three. Every ceremony on every surface raisedAttributeErrorand 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 onrp_nameand then onorigins. Each was found by a live 500, because the team signs in with magic links. The three are@propertyaliases now andtest_surface_rp_id.pycovers 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) becausesettings.WEBAUTHN_RP_IDdefaults tolocalhostandalembic upgrade headruns from laptops and CI where the variable is absent; a backfill withlocalhostwould rewrite every enrolled credential to an RP no browser matches while reporting success.WEBAUTHN_RP_ID_BACKFILLis 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.zuerichto the apex; the comment atcore/surfaces.py129 to 130 andtest_labs_rp_id_is_never_shortenedexist 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(branchd1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):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 atbackend/app_fastapi.py822, 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:Surfacewithrp_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_RP67,_backfill_rp_id70 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,SoftAuthenticator79 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.json13.
- The resolved dependency in the image's environment,
webauthn2.7.0 (a floor inrequirements.txtline 154, Gotchas):webauthn/helpers/exceptions.py(WebAuthnException(Exception)1,InvalidRegistrationResponse9,InvalidAuthenticationResponse17,InvalidJSONStructure29,SignatureVerificationException37),webauthn/authentication/verify_authentication_response.py148 to 155 (the conditional sign-counter refusal). alchemycloud(main, b3d34da):scripts/distribute-env.shlabs-prod (215 to 360, the twoWEBAUTHN_*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.
python -m pytest tests/test_surface_rp_id.py -q --no-cov -o addopts="" -W ignoreprints40 passed;python -m pytest tests/test_webauthn_multi_rp.py -q --no-cov -o addopts="" -W ignoreprints26 passed, 2 failedwith exactlytest_rate_limit_still_bites_a_cross_origin_callerandtest_prima_surfaces_get_cross_site_cookie_treatmentfailing (on a service built to this recipe:28 passed).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'].- 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'is400withRequested surface does not match the request origin.;?surface=primapvsis400naming an external surface. 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 emptyallowCredentials, achallenge_idand"surface":"labs";SELECT challenge_key FROM webauthn_challenges ORDER BY created_at DESC LIMIT 1showsdisc:labs:<uuid>; the same POST with-d '{"surface":"prima_web"}'shows"rpId":"prima.li"and a keydisc:prima_web:<uuid>.- With a session token
$Tfor 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"},pubKeyCredParamslisting-7and-257, and"surface":"labs"; a second call replaces the row underreg:labs:<user_id>(SELECT count(*) FROM webauthn_challenges WHERE challenge_key = 'reg:labs:<user_id>'prints1). - 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 = :ushowslabs.alchemy.zuerich | <n> | <name>, with<n>0on 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 showslast_used_atset andsign_countunchanged or increased, exactly1only on a counter-bearing key (Gotchas);GET /api/auth/webauthn/credentialswith the new session lists the row with itsrp_id;GET ...?surface=prima_weblists nothing. curl -s -i -X DELETE http://127.0.0.1:8000/api/auth/webauthn/credentials/<another user's credential id> -H "Authorization: Bearer $T"shows404andPasskey not found; the same with the caller's own id shows204.- In
backend:grep -c "UPDATE webauthn_credentials\|\.rp_id = " services/webauthn_service.pyprints0(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 -lprints0(the deviation; on a service built to this recipe the registry reads the settings and the count is at least1). python -c "import importlib.metadata as m; print(m.version('webauthn'))"in the image's environment prints2.7.0; a different value means the floor moved and the suite in check 1 must be rerun before the deploy.- In the same environment:
python -c "from webauthn.helpers.exceptions import InvalidAuthenticationResponse as E, InvalidJSONStructure as J; print(issubclass(E, ValueError), issubclass(J, ValueError))"printsFalse False, which is why the router'sexcept ValueErrorcatches 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 catchesWebAuthnExceptioninstead, 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))"printsTrue: 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.