All systems
Identity

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/services

1.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

ParameterValue
Users with two-factor enrolled15% of 1,000,000 = 150,000
Sign-ins per enrolled user per day1.2
Backup codes per user10
Trusted device lifetime30 days
TOTP step30 seconds, 6 digits

Code verifications

150,000 x 1.2 = 180,000 challenges/day
minus ~60% skipped on a trusted device = 72,000 verifications/day
72,000 / 86,400 = 0.8/second average

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

150,000 users x 10 codes = 1,500,000 rows
each row is a bcrypt hash plus a used stamp, ~120 bytes
1,500,000 x 120 = ~180 MB

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

150,000 users x 2 trusted devices = 300,000 rows
expiring after 30 days, so the table turns over monthly

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

12345678ClientLogin HandlerPassword ServiceTwo-Factor ServicePending TokenVerify HandlerTOTP ServiceBackup CodesToken Pair
  1. 1The client posts email and password as usual. Nothing about the first request says two-factor is involved.
  2. 2The password service verifies the hash. A wrong password ends here, with no hint about whether a second factor would have been asked for.
  3. 3The two-factor service checks whether this account has it enrolled, and whether the request carries a trusted-device cookie that is still valid.
  4. 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.
  5. 5The client prompts for a code and posts it with the pending token.
  6. 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.
  7. 7If the submitted value is not a TOTP code, it is checked against the unused backup codes and consumed on a match.
  8. 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

ComponentWhat it is
AlgorithmTOTP, RFC 6238, SHA-1, 6 digits, 30 second step
Enrolment transportotpauth:// URI rendered as a QR code
Drift windowa bounded number of steps either side of now
Backup codes10 per user, bcrypt hashed, single use
Secret storagethe field encryption system, not plaintext
Pending tokena JWT with a single claim and a short expiry

6.Data model

users (the two-factor columns)

ColumnHolds
totp_secretencrypted at rest, null until enrolled
totp_enabled_atnull until one working code confirmed enrolment

backup_codes

ColumnHolds
user_idowner
code_hashbcrypt of the code, never the code
used_atnull until consumed; a used row is kept so the remaining count is honest

trusted_devices

ColumnHolds
user_idowner
token_hashSHA-256 of the cookie value
user_agent, ipwhat the user sees in the list
expires_atbounded; a trusted device is not trusted forever

7.API design

Enrolment

MethodEndpointWhat it does
GET/api/v1/auth/totp/statusIs it on, how many backup codes remain
POST/api/v1/auth/totp/setupGenerate a secret and the otpauth URI
POST/api/v1/auth/totp/enableConfirm with one working code and switch it on
POST/api/v1/auth/totp/disableTurn it off, after re-authenticating

Signing in

MethodEndpointWhat it does
POST/api/v1/auth/totp/verifyExchange a pending token and a code for a real pair
POST/api/v1/auth/totp/backup-codesRegenerate the set, invalidating the old one

Trusted devices

MethodEndpointWhat it does
GET/api/v1/auth/trusted-devicesDevices that skip the prompt
DELETE/api/v1/auth/trusted-devices/:idStop trusting one

Completing a sign-in

{
"pending_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"code": "418265",
"trust_device": true
}

8.Low level design

Core types

totpinternal/totp/totp.go

The standard, implemented once. Secret generation, the otpauth URI, and verification against a bounded window.

GenerateSecretProvisioningURIValidateGenerateBackupCodes
TwoFactorServiceinternal/services/two_factor.go

The 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.

RequiredVerifyCodeConsumeBackupCodeTrustDeviceRemainingCodes
TOTPPendingToken

The 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

PatternWhere it is used
Two-phase authenticationa pending token that is good for one thing
One-time tokenbackup codes consumed on use and kept as tombstones
Time windowa 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.

Read next