TILLAUTH · FLOWS

MFA — first class, every plan.

GA

TOTP (the kind authenticator apps show) plus 10 single-use backup codes. No SMS fallback ever — see passkeys for a hardware-backed second factor, or Till Authenticator push approval for tap-to-confirm. Every route below is specified in the API reference.

01Spec

How TOTP works here

  • Spec: TOTP (RFC 6238) — standard 6-digit time-based codes. Compatible with every TOTP authenticator (1Password, Authy, Google Authenticator, Bitwarden, Aegis, …).
  • Secret: generated with strong randomness and encrypted at rest; never returned after enrollment.
02Setup

Enrollment

  1. Authenticated user calls POST /v1/totp/setup. We mint a secret, create a pending enrollment (not yet enabled), and return an otpauth_uri, the secret for manual entry, and a ready-made qr_png data URL.
  2. The user scans the QR and is prompted for the first 6-digit code as proof they configured their authenticator correctly.
  3. Client posts POST /v1/totp/verify-setup with the code. On match the enrollment is enabled and the response carries two things: the backup codes, and a fresh token pair with mfa: true — store it, replacing the old one, so this session counts as factor-proven.
  4. Backup codes — 10 of them, each shown once. Server-side they are stored hashed; the plaintext is gone the moment the user moves on.
  5. Audit rows: totp.setuptotp.enabled.
Who proves what
Accounts with a password confirm it (current_password). Password-less accounts (magic-link or OAuth-born) proceed on their session alone — or, once any second factor exists, must present an mfa: true session (403 step_up_required otherwise). No account is locked out of enrolling by a password it never had.
03Sign-in

Challenge

After a primary sign-in succeeds against a TOTP-enrolled user, POST /v1/signin answers a challenge instead of a session:

json
{
  "mfa_required": true,
  "challenge_token": "…"
}

The challenge token is short-lived and bound to this sign-in attempt. The client posts POST /v1/totp/verify-signin with { challenge_token, code }code is either the 6-digit TOTP or a backup code:

  • On match, a full mfa: true session is minted.
  • A wrong code is a 422 with a retryable message; attempts are rate-limited per user.
  • A backup code is checked against the hashed set and consumed on use — it can never be replayed.

Magic-link sign-ins step up the same way: /v1/magic/consume returns the same challenge shape when TOTP is enrolled.

04Recovery

Backup codes

10 codes are generated at enrollment, each single-use; consuming one removes it from the set. The remaining count is visible to your UI as factors.backup_codes on POST /v1/me — surface it, and prompt re-enrollment when it runs low.

There is no separate regenerate endpoint: to issue a fresh set, disable TOTP and enrol again (a deliberate re-proof of the authenticator, not just a button).

05Teardown

Disabling MFA

POST /v1/totp/disable, with the same proof ladder as setup: password holders confirm it, password-less users present an mfa: true session. On success the secret and remaining backup codes are destroyed; audit row totp.disabled.

409 — the floor
When TOTP is the account's last second factor on an app that requires MFA, disabling is refused with 409 and a stated reason (enrol another factor first). Removal never silently strands an account.
06Policy

Forcing MFA enrollment

Per-app owner choice in the dashboard (App → Settings → Sign-in policy), default optional. On a required app, users without a factor are routed into enrollment before receiving a full session, and policy changes that would strand existing users (nothing left to enrol) are rejected at write time.

07Non-goals

What we don't do

  • SMS 2FA — phishing-friendly and SIM-swap-vulnerable. We won't ship it; if your compliance requires “MFA via SMS”, offer passkeys.
  • Email 2FA — the second factor is the channel that proved the first. Same channel = no second factor.

Push approval, previously on this list, now exists properly: Till Authenticator binds a device-held signing key with number-matching — /v1/signin answers method: "push" and the browser polls /v1/push/signin/poll. See the API reference.


Next: magic links · passkeys for the strongest UX · or embedded account settings to build this into your own UI.