apistockdocs
v0.4 GitHub apistock.dev
Decisions/Records

Passkeys

ADR-0044Accepted (2026-09-15)

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.
  • LoadConfig checks that every origin is https (or http://localhost in 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_ID in production, passkeys are unavailable (503 passkeys_unavailable); the rest of the app runs. Passkeys are optional per deployment, unlike AUTH_ENCRYPTION_KEYS.
  • Android apps become allowed origins (android:apk-key-hash:<base64url of the certificate SHA-256>), derived from WEBAUTHN_ANDROID_APPS.
  • What developers provide, where they get each value, and how the app reports what's configured (the .env.example block, AUTH_PROVIDERS.md, the Sign-in methods status at start, auth-providers and GET /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 import passkey.
  • Browsers need HTTPS or localhost, and the RP ID can't be an IP address: http://127.0.0.1:8080 can't use passkeys in development; use http://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/passkey and passkeytest (public API, ADR-0015); modules/auth gains SendPasskeyAdded and SendPasskeyRemoved emails.
  • The Full preset gains a migration, repository files, use cases, endpoints, the .well-known routes, 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-mfa removes 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/passkey adds seven modules to modules/auth's graph (fxamacker/cbor, go-viper/mapstructure, go-webauthn/x, golang-jwt/jwt, google/go-tpm, google/uuid, tinylib/msgp) and rsc.io/qr for authenticator app QR codes; all tests and lint pass on Go 1.26.
  • The library's credential record and ceremony state are stored as jsonb and 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 (totp needs AUTH_ENCRYPTION_KEYS, passkey needs WEBAUTHN_RP_ID), plus recovery_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_cleanup removes ceremonies once they expire.
  • A failed passkey sign-in records auth.login.failed with reason invalid_passkey; a successful one records auth.login.succeeded with mfa_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
esc
↑↓ move↵ openesc close