Two-factor authentication
Users can turn on TOTP two-factor authentication (2FA) in their profile. After that, every login asks for the password, then a 6-digit code from an authenticator app (Google Authenticator, Aegis, 1Password, …) or one of ten single-use recovery codes. A platform admin can reset a user’s 2FA when the device is lost.
1. How it works
Enrollment (profile → Security)
- Turn on, then the password:
POST /api/auth/2fa/setupstores a new pending secret (20 random bytes, base32) and returns it with itsotpauth://URI. - The page draws the URI as a QR code. The
qrcodepackage is loaded only at this step. The page also shows the key in groups of four, for typing by hand. - The user enters the code the app shows:
POST /api/auth/2fa/enable. The secret becomes active, ten recovery codes are returned once, every other session is signed out, and the user gets an in-app notice and an email. - The codes are shown with Copy and Download (.txt). “Done” unlocks once “I saved them” is ticked.
Login
POST /api/auth/loginwith the right password answers{ twoFactorRequired: true, challengeToken }. That is no session: no cookie and no access token. A wrong password still gets the same 401 as before, and an unverified email still gets 403EMAIL_NOT_VERIFIEDfirst.POST /api/auth/2fa/verify { challengeToken, code }(orrecoveryCode) answers exactly like a login without 2FA: an access token and the refresh cookie.- The challenge lasts 5 minutes, works once, and allows at most 5 attempts.
- A wrong code answers 401
INVALID_TWO_FACTOR_CODE, and the user stays on the code step. - An expired, used or exhausted challenge answers 401
TWO_FACTOR_CHALLENGE_INVALID, and the client goes back to the password step.
- A wrong code answers 401
- Each user has one failure budget over a sliding 15-minute window: wrong
attempts on all of their login challenges, plus wrong codes on the
authenticated routes (
POST /api/auth/2fa/enable,/recovery-codesand/disable; a wrong password there does not count). At 10 or more, login answers 429, and so do those routes, before they look at the code. Each failure drops out 15 minutes after it happened, so the lock lifts by itself. (Verify itself ends the challenge once the count passes 10.) Wrong codes on the authenticated routes are audited asAUTH_2FA_FAILEDwithreason: INVALID_CODEandsource: account, never with the code.
Codes
- RFC 6238: SHA-1, 6 digits, 30-second steps, accepted one step either side of now (clock skew).
- Replay protection: the last accepted step is stored per user. A code is accepted only for a later step, so each code works once, including on enable, disable and regenerate.
- Recovery codes:
xxxxx-xxxxxfrom23456789abcdefghjkmnpqrstuvwxyz(no 0/o, 1/l/i). Case, spaces and dashes are ignored. Each works once. Only an HMAC-SHA256 is stored, keyed (HKDF, inforecovery-codes) fromTWO_FACTOR_ENCRYPTION_KEY, so a database leak alone cannot be brute-forced offline. New recovery codes (a TOTP code is required) replaces all ten.
What is stored
| Table | Holds |
|---|---|
user_two_factor | the secret, AES-256-GCM-sealed (key TWO_FACTOR_ENCRYPTION_KEY, AAD totp:<userId>), enabledAt, lastUsedStep |
two_factor_recovery_codes | HMAC-SHA256 of each normalized code, usedAt |
two_factor_challenges | sha256 of each challenge token, expiresAt, attempts, usedAt |
These rows are hard-deleted on disable and reset. They are not soft-delete models: a dead secret or code has no value except to an attacker. The history is in the audit log.
Audit and notices
| Event | Audit | In-app | |
|---|---|---|---|
| Enable | TWO_FACTOR_ENABLED | TWO_FACTOR_ENABLED (self) | “is on” |
| Disable | TWO_FACTOR_DISABLED | TWO_FACTOR_DISABLED (self) | “is off” |
| Admin reset | TWO_FACTOR_RESET_BY_ADMIN | TWO_FACTOR_DISABLED (admin) | “was reset” |
| New recovery codes | TWO_FACTOR_RECOVERY_CODES_REGENERATED | none | none |
| Login with a recovery code | TWO_FACTOR_RECOVERY_CODE_USED (remaining) | TWO_FACTOR_RECOVERY_CODE_USED (remaining) | “recovery code used” |
| Wrong code, dead challenge, lock | AUTH_2FA_FAILED (reason), best effort | none | none |
- The audit row, the notice, the mail and the session revocation are written in the same transaction as the change.
AUTH_2FA_FAILEDnever records the code, and never says whether it was a TOTP code or a recovery code.
Sessions
- Enable, disable and new recovery codes sign out every other session and keep the current one.
- An admin reset signs out every session.
- A password change or reset deletes the user’s open login challenges, and
creating a challenge locks the user row (
FOR SHARE) and re-checks the password hash, so a login racing a password change cannot finish on the old password. - Access tokens are stateless, so one that is already issued keeps working until it expires (15 minutes at most).
2. Recovery
- Phone lost, codes kept: log in with a recovery code. Then go to profile → Security → Turn off, with the password and another recovery code. Turn it on again with the new phone.
- Phone and codes lost: a platform admin opens Admin → Users → the shield
icon → Reset (
POST /api/users/:id/2fa/reset). The user can then log in with the password alone and enroll again. Verify who is asking first: a reset is exactly what an attacker who has the password would ask for. - Platform admins cannot reset their own 2FA this way (400); they use Turn off. Keep at least two platform admins, so one can reset the other.
3. Failure modes
| Symptom | Cause and fix |
|---|---|
| Every code is “wrong” for everyone | The server clock drifted more than 30 s. Run NTP. |
500s on 2FA login; log Two-factor secret for user … could not be decrypted | TWO_FACTOR_ENCRYPTION_KEY changed or differs between instances. Restore the old key (see Key). |
| One user gets 429 at login | 10 or more wrong codes in a sliding 15 minutes, on login or on the authenticated 2FA routes (AUTH_2FA_FAILED with LOCKED in the audit log). It clears by itself. If the user did not cause it, the password is known to someone else: reset it. |
| “Your sign-in expired” right after the password | The challenge is older than 5 minutes, or 5 codes were wrong. Log in again. |
4. Key
TWO_FACTOR_ENCRYPTION_KEY: 32 random bytes, base64 (openssl rand -base64 32).
It is required: the API refuses to boot without a valid key.
-
It must be identical on every instance and stable across deploys.
-
It is a separate key from
JOBS_ENCRYPTION_KEY. Never reuse one for the other. -
Losing it makes every enrolled secret unreadable. No 2FA user can log in until an admin resets each one.
-
Rotating it invalidates the sealed secrets and the recovery codes (their HMAC key is derived from it). There is no online rotation, because the stored envelope (
v: 1) has no key id. Plan a maintenance window:- Stop the API.
- Run a one-off script that, for each
user_two_factorrow, openssecretEncwith the old key (decryptPayload(old, row.secretEnc, 'totp:' + row.userId)) and seals it with the new one (encryptPayload(new, secret, 'totp:' + row.userId)). - Deploy with the new key. The recovery codes cannot be re-keyed (only their HMACs are stored), so every user must generate new ones, and any old code stops working.
The simpler alternative: reset everyone’s 2FA and ask them to enroll again.
5. Accepted residual risks
- Access tokens outlive a revoke. They are stateless: one already issued works for up to 15 minutes after a disable, reset or sign-out of other sessions.
- Key loss locks out every 2FA user until an admin resets each one. There is no online rotation.
- A password-holder can lock the owner out. Anyone who knows the password
can cause the 15-minute 2FA lockout (429). The audit log shows
AUTH_2FA_FAILEDwithLOCKED. - Clock sync is required. Codes tolerate one 30-second step either side; beyond that every code is “wrong”. Run NTP.
6. Production checklist
-
TWO_FACTOR_ENCRYPTION_KEYis set from the secret store, the same on every instance, and backed up with the database credentials. - The deploy’s
migrateservice applied the migrations (the 2FA tables included). - Server clocks are NTP-synced.
-
TRUST_PROXYis right (see Configuration), so the per-IP throttle on login and verify sees client IPs. - At least two platform admins exist, and the support process for “lost my phone” checks the user’s identity before a reset.
- An alert on
Two-factor secret for user … could not be decrypted. - A test account enrolls with a real authenticator app, logs out, and logs in with a code and with a recovery code. The “is on” mail arrives.
- The authenticator app shows the product’s name (
productConfig.name, for exampleKeelmint) as the issuer.
7. Local setup and tests
.envneedsTWO_FACTOR_ENCRYPTION_KEY(copy it from.env.exampleor generate one).- Unit tests:
pnpm nx test core-api-two-factor. They include the RFC 4226, RFC 6238 and RFC 4648 test vectors. - E2E:
PORT=3001 pnpm nx e2e keelmint-api-e2e.src/api/two-factor.spec.tscomputes codes with its own TOTP helper (src/support/totp.ts), deliberately independent of the API’s code.