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.

01Storage

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.

02Flow

Registration

  1. Client calls POST /v1/passkeys/register/start — we return a PublicKeyCredentialCreationOptions with a fresh challenge bound to the user. Adding a passkey to an account that already holds a second factor requires an mfa: true session (403 step_up_required otherwise).
  2. The browser shows the OS passkey UI. User confirms. The client posts the credential response to POST /v1/passkeys/register/finish.
  3. We verify the attestation, parse the authenticatorData + clientDataJSON, check the challenge against what we issued, and persist the credential.
  4. Audit row: passkey.registered.
03Flow

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 to POST /v1/passkeys/signin/finish.
  3. 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_regression warning 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).
  4. Successful signin writes signin.passkey.ok.
04Modes

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.
No SMS
We deliberately don't ship SMS fallback. SMS is a phish-and-SIM-swap target; we'd rather have you keep backup codes and trust them.
05Security

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.
06Manage

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.