Passkeys — primary, not 2FA-only.
WebAuthn the way you'd hope: a user can register a passkey and have no password. Or they can use it as a second factor on top of a password. Or both, on different devices. Counter-regression theft detection is on by default.
What's stored
For each registered credential we keep a row in tillauth_passkeys:
credential_id— the WebAuthn credentialId, base64url.public_key— the COSE-encoded public key, base64url.sign_count— last signature counter the authenticator reported. Strictly monotonic.transports— the[internal, hybrid, …]array the authenticator advertised at registration.aaguid— manufacturer identifier; useful for blocklists.label— human name (MacBook · TouchID, YubiKey 5C).
We never store derived material that would couple us to a particular WebAuthn library version. Both credential_id and public_key are the values the client gave us, verbatim.
Registration
- Client calls
POST /v1/passkeys/registration/start— we return aPublicKeyCredentialCreationOptionswith a fresh challenge bound to the user (or the pending email at signup time). - The browser shows the OS passkey UI. User confirms. The client posts the
credentialresponse toPOST /v1/passkeys/registration/finish. - We verify the attestation, parse the
authenticatorData+clientDataJSON, check the challenge against what we issued, and persist the row. - Audit row:
passkey.registered.
Authentication
- Client calls
POST /v1/passkeys/signin/startwith optional{ email }. If the email is provided we issue an allow-list of credentialIds; otherwise it's a discoverable-credential flow (the browser knows which passkey to use). - Browser prompts, returns an assertion. Client posts to
POST /v1/passkeys/signin/finish. - We look up the credential by ID, COSE-verify the signature, and check the
authenticatorData.signCount:- If the new count is greater than the stored count → update + mint session.
- If equal (some authenticators don't increment) → accept, but log a
signin.passkey.counter_regressionwarning if equality is unexpected for that AAGUID. - If strictly less → lock the credential. Write
signin.passkey.counter_regression, response 401. The user re-registers a new passkey from another device.
- Successful signin writes
signin.passkey.ok.
Primary or second factor
TillAuth doesn't separate "passkey users" from "password users". A user can have both:
- Primary passkey — sign in with passkey, no password needed. Most common for greenfield apps.
- Step-up passkey — sign in with password, second factor a passkey. The MFA challenge token from password signin is the entry point.
- Backup factor — TOTP + backup codes are the recovery path when the passkey-bearing device is lost.
We deliberately don't ship SMS fallback. SMS is a phish-and-SIM-swap target; we'd rather have you regenerate backup codes and trust them.
Theft detection
A registered passkey that fails counter monotonicity gets locked. Thetillauth_passkeys.locked_at column is set, and thelocked_reason column records why (always counter_regression today). Locked credentials are excluded from signin allow-lists and shown disabled in the user's security UI. Reactivating them is impossible by design — re-register from another device.
Removing a passkey
Authenticated users call DELETE /v1/passkeys/[id]. We require either a fresh password or another passkey assertion as proof-of-presence — otherwise stolen JWTs could mass-delete credentials. Audit row: passkey.removed.
Next: TOTP + backup codes · or OAuth if you want social login alongside passkeys.