Skip to content

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)

  1. Turn on, then the password: POST /api/auth/2fa/setup stores a new pending secret (20 random bytes, base32) and returns it with its otpauth:// URI.
  2. The page draws the URI as a QR code. The qrcode package is loaded only at this step. The page also shows the key in groups of four, for typing by hand.
  3. 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.
  4. The codes are shown with Copy and Download (.txt). “Done” unlocks once “I saved them” is ticked.

Login

  1. POST /api/auth/login with 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 403 EMAIL_NOT_VERIFIED first.
  2. POST /api/auth/2fa/verify { challengeToken, code } (or recoveryCode) answers exactly like a login without 2FA: an access token and the refresh cookie.
  3. 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.
  4. 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-codes and /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 as AUTH_2FA_FAILED with reason: INVALID_CODE and source: 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-xxxxx from 23456789abcdefghjkmnpqrstuvwxyz (no 0/o, 1/l/i). Case, spaces and dashes are ignored. Each works once. Only an HMAC-SHA256 is stored, keyed (HKDF, info recovery-codes) from TWO_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

TableHolds
user_two_factorthe secret, AES-256-GCM-sealed (key TWO_FACTOR_ENCRYPTION_KEY, AAD totp:<userId>), enabledAt, lastUsedStep
two_factor_recovery_codesHMAC-SHA256 of each normalized code, usedAt
two_factor_challengessha256 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

EventAuditIn-appEmail
EnableTWO_FACTOR_ENABLEDTWO_FACTOR_ENABLED (self)“is on”
DisableTWO_FACTOR_DISABLEDTWO_FACTOR_DISABLED (self)“is off”
Admin resetTWO_FACTOR_RESET_BY_ADMINTWO_FACTOR_DISABLED (admin)“was reset”
New recovery codesTWO_FACTOR_RECOVERY_CODES_REGENERATEDnonenone
Login with a recovery codeTWO_FACTOR_RECOVERY_CODE_USED (remaining)TWO_FACTOR_RECOVERY_CODE_USED (remaining)“recovery code used”
Wrong code, dead challenge, lockAUTH_2FA_FAILED (reason), best effortnonenone
  • The audit row, the notice, the mail and the session revocation are written in the same transaction as the change.
  • AUTH_2FA_FAILED never 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

SymptomCause and fix
Every code is “wrong” for everyoneThe server clock drifted more than 30 s. Run NTP.
500s on 2FA login; log Two-factor secret for user … could not be decryptedTWO_FACTOR_ENCRYPTION_KEY changed or differs between instances. Restore the old key (see Key).
One user gets 429 at login10 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 passwordThe 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:

    1. Stop the API.
    2. Run a one-off script that, for each user_two_factor row, opens secretEnc with the old key (decryptPayload(old, row.secretEnc, 'totp:' + row.userId)) and seals it with the new one (encryptPayload(new, secret, 'totp:' + row.userId)).
    3. 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_FAILED with LOCKED.
  • 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_KEY is set from the secret store, the same on every instance, and backed up with the database credentials.
  • The deploy’s migrate service applied the migrations (the 2FA tables included).
  • Server clocks are NTP-synced.
  • TRUST_PROXY is 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 example Keelmint) as the issuer.

7. Local setup and tests

  • .env needs TWO_FACTOR_ENCRYPTION_KEY (copy it from .env.example or 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.ts computes codes with its own TOTP helper (src/support/totp.ts), deliberately independent of the API’s code.