Security

How to add two factor authentication

September 26, 2026 · Updated September 30, 2026

A second factor is another proof, kept somewhere the password is not. Offer a passkey where the account can use one and a code from an authenticator app where it cannot, per How to choose between passkeys and one time codes. Enrolment is where the mistakes concentrate: a secret written to the database in the clear, or a user who switches the feature on without once proving the app works.

Enrolment, in order

  1. Generate 160 random bits from the operating system's cryptographic source and encode them in base32. RFC 4226, which TOTP builds on, requires at least 128 bits and recommends 160.

  2. Render the secret as a QR code holding an otpauth URI, and print the encoded string underneath it for anyone typing it into a password manager by hand.

  3. Store the secret against the account with the factor still switched off, until the user submits one valid code from it. Skip this and a user who scanned nothing, or scanned into an app they then deleted, is locked out at the next login.

  4. Issue ten single-use recovery codes, display them once, and store them hashed the way passwords are, per How to hash a password.

  5. Tell the account holder out of band. The OWASP Multifactor Authentication Cheat Sheet asks for notification through a separate channel whenever a factor changes, which catches an attacker enrolling their own device on an account they have already taken.

A QR code carries the new secret to the authenticator app, and the secret waits in a pending state, stored against the account with the factor still off. One valid code from that app switches the factor on, issues ten recovery codes and sends an out-of-band notice. The branch below takes the other route, switching the factor on at the pending step: the user scanned nothing, and nobody finds out until the next login, when they cannot get in.

The URI from step 2:

otpauth://totp/How%20To%20Guides:alice@example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DP&issuer=How%20To%20Guides&algorithm=SHA1&digits=6&period=30

The label before the colon and the issuer parameter both matter. They are what the user reads in a list of twenty accounts, and leaving them generic produces an entry called "Account".

Checking a code

The parameters come from RFC 6238. The counter is the Unix time divided by a 30 second step, counted from the epoch. The code is six digits derived with HMAC-SHA-1. An authenticator app assumes those defaults, so changing them means the user's app disagrees with your server.

The shared secret and a counter of Unix time divided by thirty go through HMAC-SHA-1 and are truncated to six digits. Below, a row of thirty second steps: the current step and one step either side are accepted to allow for network delay, while steps two away are refused. Two notes sit beneath it: store the last counter accepted and refuse anything at or below it so a code works once, and rate limit the attempts.

Three rules on the verifier side:

  • Accept one step either side of the current one. The RFC recommends at most one time step of network delay, which covers a user typing the last digit as the code rolls over.

  • A code works once, which the RFC makes normative: the verifier must not accept a second attempt of a code after the first has validated. Store the counter value you last accepted for that account and refuse anything at or below it.

  • Limit the attempts, because six digits is a million combinations and a window of three live codes makes a blind guess unlikely without making it impossible. OWASP asks for strict attempt limits. We use the same escalating delay as the password, per How to rate limit a login page, counted separately so that failing the code does not consume the password's budget.

With pyotp, the server side is four lines:

import pyotp

secret = pyotp.random_base32()                 # 160 bits, base32
totp = pyotp.TOTP(secret)                      # 30 s step, 6 digits, SHA-1
uri = totp.provisioning_uri(name="alice@example.com", issuer_name="How To Guides")

ok = totp.verify(submitted_code, valid_window=1)

valid_window=1 is the one step either side. Single use and rate limiting are yours to add around it, because the library cannot know what you have already accepted.

Storing the secret

The shared secret is worth what the password is worth. Anyone holding it generates valid codes for that account indefinitely, and unlike a password it never changes on its own.

It cannot be hashed, because the server needs the value back to derive a code. Encrypt it at rest with a key held outside the database, in a key management service or the application's configuration, so that a copy of the user table alone yields nothing. It never appears in a log line or an email. Analytics events are logs with a different name, so it stays out of those too. The only place it appears in plain form is the enrolment page, over HTTPS, and the recovery codes are hashed the moment they are shown.

Changing or removing a factor

Require the password and the current factor before any change to the factor settings, which OWASP states as reauthentication with an existing enrolled factor. Notify out of band afterwards, including on removal.

Removing the last factor from an account with administrative rights is a decision worth making harder: we refuse it in the interface and require a second administrator, because an attacker who can turn the protection off has not been slowed down by it.

Who has to have it

AccountPolicy
Administrators and anyone who can read other users' dataRequired, and a passkey where the device allows one, a code where it does not
Accounts holding payment details or personal dataOffered at signup and prompted afterwards
Everyone elseOffered, never forced
Any account, during a credential stuffing runRequired at login where a factor is enrolled, until the site-wide failure rate drops

The last row is the one that pays for itself. The site-wide failure counter from the rate limiting page is the signal, and raising the requirement for a few hours costs enrolled users one extra step.

Recovery

A factor that cannot be recovered becomes a support queue. Recovery codes issued at enrolment answer it for most accounts, and the recovery path has to be no weaker than the factor: no security questions, and no code sent to the same phone number that is already the factor. How to choose between passkeys and one time codes covers the trade-offs per factor type.

Sources