TILLAUTH · FLOWS

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

  1. Client calls POST /v1/passkeys/registration/start — we return a PublicKeyCredentialCreationOptions with a fresh challenge bound to the user (or the pending email at signup time).
  2. The browser shows the OS passkey UI. User confirms. The client posts the credential response toPOST /v1/passkeys/registration/finish.
  3. We verify the attestation, parse theauthenticatorData + clientDataJSON, check the challenge against what we issued, and persist the row.
  4. Audit row: passkey.registered.

Authentication

  1. Client calls POST /v1/passkeys/signin/start with 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).
  2. Browser prompts, returns an assertion. Client posts toPOST /v1/passkeys/signin/finish.
  3. 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 asignin.passkey.counter_regression warning if equality is unexpected for that AAGUID.
    • If strictly less → lock the credential. Writesignin.passkey.counter_regression, response 401. The user re-registers a new passkey from another device.
  4. 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.