Multi-Factor Authentication System Design
A second factor that survives a leaked password, without locking out the user who loses their phone.
internal/totpinternal/handlersinternal/services1.Problem statement
Passwords leak. They are reused across sites, phished, and dumped in breaches that have nothing to do with your application. A second factor makes a leaked password insufficient on its own.
The design tension is not the cryptography, which is a published standard and a small amount of code. It is recovery. Any second factor strong enough to stop an attacker is strong enough to lock out a user who drops their phone in a river, and a recovery path weak enough to be convenient is the new weakest link.
The second tension is where in the sign-in flow it goes. Issuing a full token and then asking for a code means the token existed before the second factor was checked. The flow has to stop halfway and issue something that is only good for finishing it.
The system has to be able to:
- Enrol an authenticator app by a scannable secret, and confirm enrolment by requiring one working code before switching it on.
- Interrupt sign-in after the password is verified but before any usable token is issued.
- Accept a time-based code, tolerating a reasonable amount of clock drift but not an unlimited amount.
- Accept a single-use backup code when the authenticator is gone, and consume it.
- Remember a device the user trusts, for a bounded time, so the factor is not demanded hourly.
- Let a user see how many backup codes remain and regenerate the set.
2.System requirements
Functional requirements
- Generate a secret and an otpauth:// URI, rendered as a QR code in the admin and the mobile app.
- Require one valid code before two-factor is marked enabled on the account.
- Return a pending token from sign-in when two-factor is enrolled, usable only to complete that sign-in.
- Verify a six-digit code against a thirty-second step, searching a bounded window either side.
- Issue ten single-use backup codes at enrolment, stored hashed, shown once.
- Optionally trust the current device for a period, skipping the prompt on it.
- Disable two-factor only after re-authenticating.
Non-functional requirements
- No usable token before the second factor. the pending token carries one claim: that this user passed the password check. It opens nothing else.
- Secrets encrypted at rest. a TOTP secret is a password equivalent. It is stored through the field encryption system, not in plaintext.
- Bounded clock tolerance. a window of a few steps either side covers real drift. An unbounded window turns a captured code into a reusable one.
- Backup codes are one-shot. each is consumed on use. A code that still works after being used is a password with extra steps.
- Recoverable. a user who has lost every factor can be helped by an operator, and that intervention is in the audit log.
3.Capacity estimation
Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.
Assumptions
| Parameter | Value |
|---|---|
| Users with two-factor enrolled | 15% of 1,000,000 = 150,000 |
| Sign-ins per enrolled user per day | 1.2 |
| Backup codes per user | 10 |
| Trusted device lifetime | 30 days |
| TOTP step | 30 seconds, 6 digits |
Code verifications
Verification is an HMAC over a counter, repeated across a small window. The cost is irrelevant; the storage and the recovery path are what matter.
Backup code storage
Codes are hashed with the same function as passwords, so verifying one costs a bcrypt comparison. Ten per user bounds the work when a code is presented.
Trusted devices
4.High level design
Two-factor splits sign-in into two requests. The first proves the password and returns a token that can do nothing but finish; the second proves possession and exchanges it for a real pair.
Core components
- TOTP service. generates secrets, builds the otpauth URI, and verifies a code against the current time step and a bounded window either side.
- Pending token. a short-lived token with a single claim. It is accepted by exactly one endpoint and nothing else on the API will take it.
- Backup code store. ten hashed codes per user, each with a used stamp. A used code is kept so the count shown to the user is honest.
- Trusted device register. a cookie-bound record that lets a known device skip the prompt for a bounded period.
- Two-factor service. the decisions: is it enrolled, is this device trusted, did this code work, how many codes are left.
Request flow
Sign-in interrupted by a second factor
- 1The client posts email and password as usual. Nothing about the first request says two-factor is involved.
- 2The password service verifies the hash. A wrong password ends here, with no hint about whether a second factor would have been asked for.
- 3The two-factor service checks whether this account has it enrolled, and whether the request carries a trusted-device cookie that is still valid.
- 4If a factor is needed, sign-in stops. A pending token is issued: short-lived, single-purpose, and accepted by one endpoint. No access token exists at this point.
- 5The client prompts for a code and posts it with the pending token.
- 6The TOTP service recomputes the expected code for the current step and a bounded window either side, to absorb clock drift without accepting an old code indefinitely.
- 7If the submitted value is not a TOTP code, it is checked against the unused backup codes and consumed on a match.
- 8Only now is a real token pair issued and a session created. If the user asked to trust the device, the register gets a row and the response sets the cookie.
Data flow
- The TOTP secret is written encrypted and decrypted only inside the verification call. It is never returned by an API after enrolment.
- Backup codes are shown exactly once, at generation. The server keeps bcrypt hashes and cannot display them again.
- The pending token is not a session. Nothing is written to the sessions table until the second factor passes.
- A trusted device is bound to a cookie value and an expiry, not to an address, because addresses change and users move.
5.Technology stack
| Component | What it is |
|---|---|
| Algorithm | TOTP, RFC 6238, SHA-1, 6 digits, 30 second step |
| Enrolment transport | otpauth:// URI rendered as a QR code |
| Drift window | a bounded number of steps either side of now |
| Backup codes | 10 per user, bcrypt hashed, single use |
| Secret storage | the field encryption system, not plaintext |
| Pending token | a JWT with a single claim and a short expiry |
6.Data model
users (the two-factor columns)
| Column | Holds |
|---|---|
| totp_secret | encrypted at rest, null until enrolled |
| totp_enabled_at | null until one working code confirmed enrolment |
backup_codes
| Column | Holds |
|---|---|
| user_id | owner |
| code_hash | bcrypt of the code, never the code |
| used_at | null until consumed; a used row is kept so the remaining count is honest |
trusted_devices
| Column | Holds |
|---|---|
| user_id | owner |
| token_hash | SHA-256 of the cookie value |
| user_agent, ip | what the user sees in the list |
| expires_at | bounded; a trusted device is not trusted forever |
7.API design
Enrolment
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/auth/totp/status | Is it on, how many backup codes remain |
| POST | /api/v1/auth/totp/setup | Generate a secret and the otpauth URI |
| POST | /api/v1/auth/totp/enable | Confirm with one working code and switch it on |
| POST | /api/v1/auth/totp/disable | Turn it off, after re-authenticating |
Signing in
| Method | Endpoint | What it does |
|---|---|---|
| POST | /api/v1/auth/totp/verify | Exchange a pending token and a code for a real pair |
| POST | /api/v1/auth/totp/backup-codes | Regenerate the set, invalidating the old one |
Trusted devices
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/v1/auth/trusted-devices | Devices that skip the prompt |
| DELETE | /api/v1/auth/trusted-devices/:id | Stop trusting one |
Completing a sign-in
{"pending_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","code": "418265","trust_device": true}
8.Low level design
Core types
The standard, implemented once. Secret generation, the otpauth URI, and verification against a bounded window.
GenerateSecretProvisioningURIValidateGenerateBackupCodesThe decisions, lifted out of the handler so they can be tested without HTTP. Whether a factor is required, whether a code or a backup code matched, how many remain.
RequiredVerifyCodeConsumeBackupCodeTrustDeviceRemainingCodesThe half-finished sign-in, as a model rather than an implicit convention. Short-lived and accepted by one endpoint.
Design principles applied
- Separate the standard from the policy. the TOTP package knows RFC 6238 and nothing about users. The service knows the policy and nothing about HMAC.
- Confirm before enabling. enrolment requires a working code. Without that step, a user who mis-scans the QR locks themselves out at the next sign-in.
- Make recovery explicit. backup codes are generated at enrolment, not offered later as an afterthought, because the moment a user needs them is the moment they cannot reach the settings page.
Patterns
| Pattern | Where it is used |
|---|---|
| Two-phase authentication | a pending token that is good for one thing |
| One-time token | backup codes consumed on use and kept as tombstones |
| Time window | a bounded search either side of the current step |
9.Scalability and performance
- Verification is pure computation over a handful of steps. It adds nothing measurable to the sign-in path.
- Backup code verification is a bcrypt comparison against up to ten hashes, so a wrong code costs ten comparisons. That bounds the work and is the reason the set is small.
- Trusted devices remove most of the prompts, which is a user-experience decision with a capacity side effect: the verification rate is a fraction of the sign-in rate.
- Nothing here is shared state beyond the database, so it scales with the API.
- The drift window is the one knob with a security cost. Widening it to help users with bad clocks lengthens the life of a captured code.
10.Bottlenecks and improvements
What breaks first
- Lockout with no recovery. a user who enrols, never saves the backup codes and loses the phone is locked out permanently unless an operator can intervene.
- Code replay inside the window. a code is valid for its whole step and the drift window. An attacker who reads one over the user’s shoulder has a real, if short, opportunity.
- Trusted devices as a soft spot. a trusted-device cookie is a bearer credential that skips the second factor. On a shared machine it defeats the point.
- Phishable by design. TOTP is a shared secret. A convincing proxy page can collect the code and use it within the window, which passkeys solve and TOTP does not.
What to do about it
- Record used codes. remembering the last accepted step per user rejects the same code twice and closes the shoulder-surfing window.
- Nudge the codes. showing the remaining count on the account page, and prompting when it reaches two, catches the lockout before it happens.
- Bound and show trusted devices. a short expiry plus a visible list, revocable in one click, keeps the convenience without making it invisible.
- Offer passkeys alongside. for the phishing case the answer is a factor that is bound to the origin. Both are supported, and a user can hold either or both.
