Passkeys
Status: Accepted (2026-09-15) · Amends: ADR-0024, ADR-0043
Context#
ADR-0024 puts passkeys in v0.3: WebAuthn registration and sign-in, a configured relying-party ID and allowed origins, passkeys counting as a strong second factor, and apple-app-site-association and assetlinks.json for native apps. ADR-0043 built two-factor authentication with TOTP, a sign-in challenge (POST /v1/auth/login → 202 → POST /v1/auth/login/mfa), sessions verified with a second factor, and roles that require one.
Threat model row 16 (passkey phishing or origin confusion) is addressed by validating the relying-party ID and allowed origins. TOTP stays phishable; passkeys are the factor that isn't.
Three choices were made with the maintainer (2026-09-15):
| Question | Choice |
|---|---|
| What passkeys do | Passwordless sign-in and a second factor for password sign-ins |
| Where the relying-party ID and origins live | Environment variables: changing them is a phishing risk, so it needs a deploy and can't be done from /ops/settings (ADR-0031's "infrastructure" layer) |
| Native app association files | Included now, served only when configured |
Options#
WebAuthn implementation#
1. github.com/go-webauthn/webauthn |
2. Our own verification | |
|---|---|---|
| Scope | Registration and assertion ceremonies, CBOR and COSE keys, every attestation format, origin and RP ID checks, backup flags, counters | CBOR, COSE, client data, authenticator data and signature checks written and maintained by us |
| Maturity | Widely used, maintained (v0.18.1, September 2026) | New security-critical code |
| Dependencies | fxamacker/cbor, go-viper/mapstructure, go-webauthn/x, golang-jwt/jwt, google/go-tpm, google/uuid, tinylib/msgp |
None |
Option 1, isolated behind a package so apps never import its types (threat 22).
Decision#
Who owns what (as ADR-0038)#
Library: apistock.dev/modules/auth/passkey |
App: internal/modules/auth |
|---|---|
passkey.Config (RP ID, display name, origins, Android apps) and passkey.New, which validates it |
Registration, sign-in and second-factor flows, limits, tables, endpoints |
BeginRegistration / FinishRegistration, BeginLogin (discoverable, or for one user's credentials) / FinishLogin, taking and returning JSON and plain structs |
Stores the ceremony state and credential records as the library returns them |
Credential: ID, public key record, AAGUID, backup flags, sign count; UserHandle generation (64 random bytes) |
auth_passkeys, auth_users.webauthn_user_handle |
AppleAppSiteAssociation and AssetLinks documents |
GET /.well-known/apple-app-site-association, GET /.well-known/assetlinks.json |
passkeytest: a software authenticator that creates and signs real registration and assertion responses, for tests in the library and generated apps |
Tests on Docker PostgreSQL and over HTTP |
It is a separate package of the modules/auth module, so apps that don't import it don't compile it.
Configuration (environment)#
| Variable | Meaning | Development default |
|---|---|---|
WEBAUTHN_RP_ID |
The relying-party ID: the site's registrable domain, such as example.com |
localhost |
WEBAUTHN_ORIGINS |
Comma-separated browser origins allowed to use passkeys, such as https://app.example.com |
http://localhost:8080,http://localhost:3000 |
WEBAUTHN_APPLE_APP_IDS |
Comma-separated TEAMID.bundle.id of iOS apps |
none |
WEBAUTHN_ANDROID_APPS |
Comma-separated package.name=SHA256:FINGERPRINT entries (several fingerprints separated by +) |
none |
- The display name is the app's
ServiceName. LoadConfigchecks that every origin ishttps(orhttp://localhostin development) and that its host is the RP ID or a subdomain of it; the library checks the rest when it starts.- Without
WEBAUTHN_RP_IDin production, passkeys are unavailable (503passkeys_unavailable); the rest of the app runs. Passkeys are optional per deployment, unlikeAUTH_ENCRYPTION_KEYS. - Android apps become allowed origins (
android:apk-key-hash:<base64url of the certificate SHA-256>), derived fromWEBAUTHN_ANDROID_APPS. - What developers provide, where they get each value, and how the app reports what's configured (the
.env.exampleblock,AUTH_PROVIDERS.md, the Sign-in methods status at start,auth-providersandGET /ops/auth/providers) follow ADR-0045.
Native app association files#
| Path | Served when | Content |
|---|---|---|
GET /.well-known/apple-app-site-association |
WEBAUTHN_APPLE_APP_IDS is set |
{"webcredentials": {"apps": [...]}}, Content-Type: application/json |
GET /.well-known/assetlinks.json |
WEBAUTHN_ANDROID_APPS is set |
delegate_permission/common.get_login_creds statements for each package and fingerprint |
Otherwise both are 404. The files must be reachable on the RP ID's domain; when that domain is a separate web frontend, it proxies or copies them (documented).
Tables#
| Table | Columns (beyond IDs and times) |
|---|---|
auth_passkeys |
user_id, credential_id (unique), credential (the library's record as JSON: public key, flags, sign count, attestation format), name (the user's label), aaguid, backup_eligible, backup_state, last_used_at |
auth_users |
new webauthn_user_handle (64 random bytes, unique), set when the first passkey is registered; never the user ID, so a credential reveals nothing about the account |
auth_webauthn_ceremonies |
token_hash (unique), user_id (NULL for passwordless sign-in), purpose (register, login, second_factor, reauth), mfa_challenge_id, session_data (the library's state as JSON), expires_at (5 minutes), consumed_at |
Registering a passkey#
| Endpoint | Behaviour |
|---|---|
POST /v1/auth/passkeys/registration |
Signed in. Body carries the password, unless the session verified a second factor in the last 10 minutes (see Amendment). Returns a ceremony_token and the options for navigator.credentials.create(): resident key required, user verification required, attestation none, the user's existing passkeys excluded |
POST /v1/auth/passkeys |
Body: ceremony_token, credential (the browser's response) and an optional name. Stores the passkey (201), marks the current session verified with a second factor, and returns 10 recovery codes when the user had none yet (the first second factor creates them). Emails "passkey added" |
GET /v1/auth/passkeys |
The user's passkeys: ID, name, created and last used, backed up |
PATCH /v1/auth/passkeys/{id} |
Rename |
DELETE /v1/auth/passkeys/{id} |
Needs a session that verified a second factor in the last 10 minutes, or the password. Refused with 409 mfa_required_by_role when it's the user's last second factor and a role requires one. Emails "passkey removed" |
At most 10 passkeys per user (409 passkey_limit_reached).
Signing in#
Passwordless. POST /v1/auth/passkeys/login/options (public) returns a ceremony_token and options for navigator.credentials.get() with no allowed credentials (a discoverable sign-in) and user verification required. POST /v1/auth/passkeys/login takes the ceremony_token, credential and transport, finds the account by user handle and credential ID, and returns 200 with a session verified with a second factor, like POST /v1/auth/login/mfa. The account must have a verified email. Unknown credentials, wrong signatures, a missing user-verification flag, expired or used ceremonies, and deleted accounts all return 401 invalid_passkey.
Second factor. For a user with passkeys, the 202 challenge from POST /v1/auth/login lists passkey in methods. POST /v1/auth/login/mfa/passkey takes the challenge_token and returns a ceremony_token and options limited to that user's passkeys; POST /v1/auth/login/mfa then accepts {"challenge_token", "passkey": {"ceremony_token", "credential"}, "transport"}. Failures count as challenge attempts, as with codes (ADR-0043).
Rules#
| Topic | Decision |
|---|---|
| Two-factor authentication on | A confirmed TOTP secret or at least one passkey. mfa_enabled in /v1/auth/me and the role policy use this |
| Turning TOTP off | Allowed with a passkey left, even when a role requires 2FA |
| Recovery codes | Created by the first second factor, TOTP or passkey; kept while any second factor remains; deleted with the last one |
| User verification | Required for registration and passwordless sign-in; required for the second-factor ceremony too, so every passkey use proves the person, not only the device |
| Sign counter | A counter that doesn't increase (other than synced passkeys reporting 0) fails the ceremony with invalid_passkey and records auth.passkey.clone_warning |
| Backup flags | Stored and shown (backed_up), not enforced |
| Attestation | none: no metadata service, no device allowlist |
| Ceremonies | Single use, 5 minutes, bound to the purpose and, for second factors, to the sign-in challenge |
| Rate limits | The per-IP auth limit covers passkey sign-in; second-factor attempts count as in ADR-0043 |
| Operator reset | reset-mfa also removes the user's passkeys |
Audit actions and emails#
auth.passkey.registered, auth.passkey.renamed, auth.passkey.removed, auth.login.succeeded (metadata method: passkey), auth.login.failed (reason invalid_passkey), auth.mfa.challenge_succeeded (method passkey), auth.passkey.clone_warning. Credential IDs are recorded as the passkey's row ID, never the raw credential. Emails: passkey added, passkey removed.
Error codes#
invalid_passkey (401), passkey_not_found (404), passkey_limit_reached (409), passkeys_unavailable (503); mfa_required_by_role (409) and invalid_mfa (401) as in ADR-0043.
Why#
- Passkeys resist phishing: the browser binds each signature to the origin, and the server checks the RP ID and origins it was configured with.
- Environment configuration keeps the phishing-relevant part of passkeys out of reach of a compromised ops account (threat 23).
- A maintained library for WebAuthn's parsing and verification is safer than new cryptographic code; wrapping it keeps apps free of its types and its churn.
- Treating a passkey sign-in as verified lets ops users drop TOTP entirely.
- A software authenticator in tests exercises the real verification path instead of mocks.
Trade-offs#
- Seven more modules in
modules/auth's graph, compiled only into apps that importpasskey. - Browsers need HTTPS or
localhost, and the RP ID can't be an IP address:http://127.0.0.1:8080can't use passkeys in development; usehttp://localhost:8080. - Rejecting a counter that goes backwards can lock out an authenticator with a buggy counter; the user signs in another way and removes it.
- Association files on a domain the API doesn't serve need the frontend's help.
- No seed passkey: registering one needs a browser or device.
Consequences#
modules/auth/passkeyandpasskeytest(public API, ADR-0015);modules/authgainsSendPasskeyAddedandSendPasskeyRemovedemails.- The Full preset gains a migration, repository files, use cases, endpoints, the
.well-knownroutes,WEBAUTHN_*in.env.example, and tests for registration, both sign-in paths, exclusions, limits, counters, the last-factor rule and the association files. - ADR-0043's "two-factor authentication on" becomes TOTP or passkeys;
reset-mfaremoves passkeys too. - Threat model row 16 is addressed when this ships.
Amendment: confirming the user (2026-09-15)#
A principal-engineer review before merge found that a session stayed "verified with a second factor" for its whole life. A stolen session verified weeks earlier could register the attacker's passkey without the password, and the passkey outlived password reset and logout-all. Accounts with only passkeys also had no way to give a second factor outside sign-in, so deleting the account needed a recovery code.
| Topic | Decision |
|---|---|
| Recent verification | auth.Principal carries MFAVerifiedAt; RecentlyVerified is true for 10 minutes (auth.RecentVerification). Verifying a second factor (sign-in, confirming the authenticator app, adding a passkey) sets the time again |
| Adding or removing a passkey | No password only within those 10 minutes. Otherwise the password, and with two-factor authentication on, a session that verified a second factor (401 invalid_mfa otherwise) |
| First second factor | Adding the first passkey signs out other devices, as confirming the authenticator app does |
| Confirming with a passkey | POST /v1/auth/passkeys/verification (signed in) returns options limited to the user's passkeys; the ceremony (purpose reauth) is bound to the user and to no sign-in challenge. DELETE /v1/auth/me, DELETE /v1/auth/mfa/totp and POST /v1/auth/mfa/recovery-codes accept its response as passkey. A sign-in's second-factor ceremony doesn't count, and a verification ceremony doesn't finish a sign-in |
| Replacing recovery codes | A code or a passkey response; never a recovery code, and no longer "any session verified with a passkey" |
| Signature counter | The passkey row is locked (FOR UPDATE) during a sign-in, so simultaneous sign-ins update it one after the other |
| Ceremony cleanup | auth_cleanup deletes ceremonies once expired, since public endpoints create them |
Rate limits stay per instance (ADR-0043); limits shared across instances are a v1.0 hardening item.
Implementation notes (2026-09-15)#
modules/auth/passkeyadds seven modules tomodules/auth's graph (fxamacker/cbor,go-viper/mapstructure,go-webauthn/x,golang-jwt/jwt,google/go-tpm,google/uuid,tinylib/msgp) andrsc.io/qrfor authenticator app QR codes; all tests and lint pass on Go 1.26.- The library's credential record and ceremony state are stored as
jsonband passed back unchanged; the app never parses them. - Two-factor authentication is on with a confirmed authenticator app or at least one passkey: sign-in challenges list only the methods this server can check (
totpneedsAUTH_ENCRYPTION_KEYS,passkeyneedsWEBAUTHN_RP_ID), plusrecovery_code. - Turning the authenticator app off keeps the recovery codes while a passkey remains; removing the last second factor deletes them.
- Replacing recovery codes, account deletion and turning the authenticator app off take a passkey response too (see Amendment).
auth_cleanupremoves ceremonies once they expire.- A failed passkey sign-in records
auth.login.failedwith reasoninvalid_passkey; a successful one recordsauth.login.succeededwithmfa_method: passkey.
| Check | Result |
|---|---|
Library (passkeytest software authenticator) |
Registration and both sign-in paths; exclusions; wrong origin, missing user verification, garbage and other-challenge responses refused; unknown credentials; lookup errors passed through; clone warning with a repeated counter; synced passkeys with counter 0; config validation; Apple and Android parsing, association files and the Android origin |
| Use cases (Docker PostgreSQL) | Registration with and without the password, recovery codes for the first second factor, single-use ceremonies, passwordless sign-in, passkey second factor bound to its challenge, list, rename, remove, wrong origin, missing user verification, clone warning audited, unknown passkey, the 10-passkey limit, no configuration, last-factor rule with roles, authenticator app turned off with a passkey left, operator reset |
| HTTP | Add, passwordless sign-in, used ceremony refused, second factor, list, rename, remove; both .well-known files served when configured and 404 otherwise; WEBAUTHN_* validation |