Two-factor authentication
A time-based one-time password on the desktop console, with recovery codes, per-tenant enforcement, and an audit row for every enrolment and reset.
What this replaced
Nothing. Before this, POST /api/v1/auth/login verified a password and
returned a token pair, and no user or administrator could make that
insufficient. Passkeys existed on /responder/* — the mobile PWA — so
the surface an analyst works from all day was single-factor by
construction.
Enrolling
Settings → Two-factor auth → Set up. The console shows a key to add to any authenticator app, you enter the code it displays, and the response carries ten recovery codes.
The recovery codes exist in plaintext exactly once. They are stored as SHA-256 hashes — not bcrypt, deliberately: a code is 100 bits of generated entropy with no dictionary to slow an attacker down, and verification walks every unused code you hold, so ten bcrypt comparisons per attempt would be a second of CPU on the login path.
An enrolment is only a credential once it is confirmed. Starting one and closing the tab locks nobody out.
Signing in
A correct password for an enrolled account answers HTTP 202 with a
short-lived mfa_token instead of a session:
{ "mfa_required": true, "mfa_token": "eyJ…", "methods": ["totp", "recovery_code"] }
POST /api/v1/auth/mfa/verify with that token and a code — either from
the app or one of the recovery codes — returns the ordinary token pair.
access_token and refresh_token are required fields of
TokenResponse. Making either optional is a break for every generated
client, which scripts/openapi_diff.py correctly refuses. A 401 would
have collided with the console's own handleUnauthorized, which clears
stored credentials and bounces to /login.
A code is spent once it is used. last_used_step records the 30-second
step it authenticated at, so a code read over your shoulder cannot be
replayed for the rest of its window. The practical effect worth knowing:
the code you confirm an enrolment with is not reusable to sign in a few
seconds later — wait for the next one.
Requiring it across a tenant
Settings → Two-factor auth → Require for everyone, or
PUT /api/v1/auth/mfa/policy with {"require_totp": true}. Needs
settings:write.
Members who have not enrolled are asked to enrol at their next
sign-in, not locked out: the 202 they receive carries
mfa_enrollment_required and a challenge token the enrolment endpoints
accept, and the login page walks them through it.
The trade-off is stated rather than hidden: somebody holding a stolen
password can enrol their own authenticator before the real user does.
It is bounded by the password still being required, by the window closing
as soon as the real user enrols, and by mfa.enrolled being audited with
the source address. Refusing instead would lock out every unenrolled
member of the tenant at the moment the policy is turned on.
A tenant that has never visited this endpoint has no policy row at all, and absence means not required. No migration writes one — a backfill of a row per tenant would change the meaning of every predicate that counts rows in that table.
Losing a phone
Use a recovery code. Each works once.
With none left, an administrator holding users:write can clear the
factor:
POST /api/v1/auth/mfa/reset/{user_id}
{ "reason": "lost phone, verified by voice" }
Three constraints, because an administrative reset is normally the weakest link in an MFA deployment:
- the target must be in the caller's own tenant, taken from the authenticated principal and never from the request;
- the
reasonis required and lands in the audit row; - an administrator may not reset their own factor here. That would be
a self-disable with no code. Removing your own factor goes through
DELETE /api/v1/auth/mfa, which requires a current code — otherwise a stolen session could strip the thing protecting it.
What is audited
mfa.enrolled, mfa.disabled, mfa.reset, mfa.recovery_code_used and
mfa.policy_changed, all on the tenant's own hash-chained audit log, each
carrying the source address.
Storage
The TOTP secret is vault-encrypted (vault:v1: / vault:v2:), never the
base32 you scanned. A database dump that yields TOTP secrets yields the
second factor for every account in it, which is most of the reason for
having one. A secret this deployment can no longer decrypt — a rotated
credential key with no rotation-from value — fails closed: sign-in is
refused with a message naming the cause, and an administrator resets the
factor.