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 we keep
For each registered credential we keep only what WebAuthn needs to verify future sign-ins:
- The credential ID the authenticator issued.
- The credential's public key, exactly as the browser returned it.
- The last signature counter the authenticator reported — strictly monotonic, used for theft detection (below).
- The transports the authenticator advertised (
internal,hybrid, …). - The authenticator's manufacturer identifier.
- A human-friendly label (MacBook · TouchID, YubiKey 5C).
We never store derived material that would couple us to a particular WebAuthn library version — the credential ID and public key are the values the client gave us, verbatim.
Registration
- Client calls
POST /v1/passkeys/register/start— we return aPublicKeyCredentialCreationOptionswith a fresh challenge bound to the user. Adding a passkey to an account that already holds a second factor requires anmfa: truesession (403step_up_requiredotherwise). - The browser shows the OS passkey UI. User confirms. The client posts the
credentialresponse toPOST /v1/passkeys/register/finish. - We verify the attestation, parse the
authenticatorData+clientDataJSON, check the challenge against what we issued, and persist the credential. - 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 reported signature counter:
- 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 authenticator model. - If strictly less → lock the credential. Write
signin.passkey.counter_regression, response 401. The owner recovers via unlock-or-remove (below).
- 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 sign-in is the entry point.
- Backup factor — TOTP + backup codes are the recovery path when the passkey-bearing device is lost.
Theft detection — lock, then unlock or remove
A registered passkey that fails counter monotonicity gets locked on sight — a regressed counter is what a cloned authenticator looks like. The credential is marked locked with the reason recorded (counter_regression), excluded from sign-in, and listed with locked_at/locked_reason by GET /v1/passkeys. But most regressions are benign — a device restored from backup, or migrated — so the lock is recoverable, not terminal:
POST /v1/passkeys/[id]/unlock— for a restore the user recognises. The stored counter re-syncs on the next assertion. Step-up-gated when the account holds another live factor.DELETE /v1/passkeys/[id]— for a lock the user does not recognise. Locked passkeys are always deletable.
Removing a passkey
Authenticated users call DELETE /v1/passkeys/[id] (realm-strict Bearer). Removing a live passkey that is the account's last way in — or its last second factor on an MFA-required app — is refused with 409 and a stated reason (e.g. “Set a password before removing it.”): removal never strands an account. Audit row: passkey.removed.
Next: TOTP + backup codes · or OAuth if you want social login alongside passkeys.