Skip to content
Chapter 53Lesson 6

TOTP and recovery codes

Add a second authentication factor with Better Auth's two-factor plugin, from authenticator-app codes to recovery-code fallback.

Your app has grown up. It charges cards now, and there is an admin console where a support agent can read any customer’s data. A password is something the person knows, a single lock on both of those doors, and a single lock fails the moment that password leaks, gets reused on a site that was breached, or is typed into a convincing fake. For surfaces that touch money or other people’s data, the year-1 baseline is to add a second, independent lock: a factor the attacker would have to steal separately. The universal choice for that second factor is a six-digit code from an authenticator app, called TOTP. It works with every authenticator on every operating system, offline, with no hardware to buy and no SMS to intercept. Back in the password sign-in lesson you built a sign-in that already returns requires-second-factor and hands the form the available methods, then stops there. This lesson builds what happens next: how a user turns this tier on, how the code finishes their sign-in, and how they get back in when their phone falls in a lake. We will move through four beats in order: enroll, challenge, recover, and control.

A six-digit number sounds like the flimsiest possible secret, so before any wiring it is worth understanding why it isn’t.

Start with the naive version, then we’ll tighten it. At setup, the server and your authenticator app agree on one shared secret : a long random value, around 160 bits, written in base32 so it survives being scanned or typed by hand. That secret is generated once and stored on both ends. After setup it never travels again. Nothing about it goes over the wire when you sign in.

So how does a code prove you hold the secret without sending it? Both sides run the same calculation, independently: they take the secret, take the current time chopped into 30-second windows, and feed both through HMAC , then truncate the result to six digits. The same secret plus the same 30-second window produces the same six digits on your phone and on the server, computed in parallel, never compared by sending one to the other. This is TOTP , the time-based one-time password.

Shared secret JBSWY3DPEHPK3PXP
Clock window T = floor(now / 30s)
identical on both sides · never re-sent
Authenticator app
HMAC(secret, T) truncate 739 204
Server
HMAC(secret, T) truncate 739 204
Same secret, same clock window, two independent computations — and only the result is ever typed in.

Two refinements make this real. First, clocks drift: your phone and the server are never perfectly in sync, and you might type a code a few seconds before it rolls over. So the server accepts the current window plus one window on either side, about 90 seconds of tolerance, to absorb that clock skew . That ±1 window is also exactly why the verify endpoint must be rate-limited: six digits is only a million possibilities, so a widened acceptance window with no cap on guesses is brute-forceable. Better Auth caps the attempts. We name the rate-limiting wiring at the call site and build it properly in a later chapter.

Second, be honest about what TOTP does not cover, because overselling a security control is how teams end up trusting one that doesn’t hold. TOTP defeats the attacks passwords lose to: a leaked database, a password reused from a breached site, credential stuffing . The attacker has the password and still can’t get in, because they don’t hold the secret. What TOTP does not defeat is real-time phishing: a proxy page that mimics your login can capture the six digits the instant you type them and replay them to the real site inside the 90-second window. The code is one-time, but “one time” is plenty when the relay is live. That gap is the line passkeys cross, because they bind to the real site’s origin, so a look-alike domain can’t relay anything. Passkeys are the subject of the next lesson. One last placement: a code from an authenticator app is the floor for a second factor. An SMS text message is weaker still, because an attacker can hijack your phone number through a SIM swap. We name SMS here only so you know to skip it.

The shape here is the same plugin pattern you used for magic links: two registrations and a schema regeneration. Better Auth ships TOTP as the twoFactor plugin, and you wire it on both the server and the client.

The server half goes in your lib/auth.ts plugins array, and the client half goes in lib/auth-client.ts. If you register the server plugin and forget the client one, authClient.twoFactor.* simply won’t exist. This is the same trap you hit with magic links, and the reason the two halves are taught together.

lib/auth.ts
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'Acme',
totpOptions: { period: 30, digits: 6 },
}),
],
});
// lib/auth-client.ts
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient(),
],
});

The server half: import twoFactor and add it to the plugins array in lib/auth.ts. This is one of the two registrations; the other lands on the client.

lib/auth.ts
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'Acme',
totpOptions: { period: 30, digits: 6 },
}),
],
});
// lib/auth-client.ts
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient(),
],
});

issuer is the label the authenticator app shows in its account list (“Acme: ada@acme.com”), so set it to the product name. totpOptions here just restates the RFC 6238 defaults (period: 30, digits: 6, SHA1). They match every major authenticator, so changing digits or the algorithm silently breaks them. This is a default you keep, unlike the deliberate password-length divergence earlier in the chapter.

lib/auth.ts
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'Acme',
totpOptions: { period: 30, digits: 6 },
}),
],
});
// lib/auth-client.ts
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient(),
],
});

The client half in lib/auth-client.ts. Forget it and authClient.twoFactor.* is simply undefined: the same trap as magic links, and why the two halves are taught together.

lib/auth.ts
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'Acme',
totpOptions: { period: 30, digits: 6 },
}),
],
});
// lib/auth-client.ts
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient(),
],
});

Registered bare, with no onTwoFactorRedirect. That callback is the plugin’s built-in way to bounce the browser to a standalone /sign-in/two-factor page when a sign-in needs the second factor. The course doesn’t use it, because the sign-in action already detects the same signal on its success channel (the { status: 'second-factor', methods } branch you built in the sign-in lesson), and the form renders the prompt inline. It is one signal with two faces, and we keep the one already wired into the form.

1 / 1

A note on issuer: it is purely cosmetic but worth setting, because it becomes the account name in the user’s authenticator app list. Set it to your product name so “Acme: ada@acme.com” is what they see, not a bare email next to twelve other codes.

Now regenerate the database tables. This is the same @better-auth/cli generate workflow you ran when adding any plugin: the plugin declares the columns it needs and the CLI writes them into your Drizzle schema for a migration. Two things land.

twoFactor new table

one row per user

userId text FK → user.id
secret text base32 TOTP secret
backupCodes text recovery codes, hashed
verified boolean did enrollment finish?
user existing table

+ one column

…existing columns…
twoFactorEnabled boolean new the on/off switch the app reads
Two things land, in two different places: a whole new twoFactor table (one row per user), and a single twoFactorEnabled column bolted onto the user table you already had.

Read those two facts carefully, because the app reads each from a different place. The flag the rest of your code checks for “does this user have 2FA on?” is user.twoFactorEnabled. The twoFactor table’s verified column answers a narrower question: did the secret on this row finish enrollment? They are not the same flag, and the next section is about the gap between them.

One word on sensitivity. The secret grants the second factor only. It is not the password, and it sits a notch below account.password in blast radius, but it is still sensitive. Better Auth stores the secret server-side and the codes hashed. For an elevated-risk product, the production reach is to encrypt the secret column itself at rest with a key from a KMS , so a leaked database dump doesn’t even surface the secrets. We name that as the reach and move on; it is not part of this build.

Enrolling a device: six steps inside a live session

Section titled “Enrolling a device: six steps inside a live session”

Start by getting one distinction straight, because missing it is the mistake almost everyone makes. There are two completely different moments where a user types a TOTP code. They look identical but are governed by different rules. The first is enrollment, turning the feature on. The second is the sign-in challenge, using it to finish logging in. This section is enrollment; the next is the challenge. Keeping them apart is the spine of the whole lesson.

Enrollment happens at a settings page, say /settings/security/2fa, while the user is already signed in. It is not part of sign-in. Because the user is already in, the gate here can’t be “are you logged in?”: they obviously are. The gate is elevation, which means re-proving the password. Turning this security tier on, and later off, must be impossible from a session you didn’t open yourself. Picture a borrowed laptop with a session left open, or a co-worker’s machine left unlocked: that session can read the app, but it must not be able to change the security posture of the account. So enabling 2FA demands the password again, right now, regardless of how the session began. This is the same re-authentication discipline the code conventions name for the high-stakes mutations, namely enable 2FA, change password, and delete account, and the plugin bakes it in by taking the password directly.

Walk the six steps:

  1. The user clicks “Enable two-factor” on the security settings page.
  2. The action calls authClient.twoFactor.enable({ password }). That password is the elevation proof. In the course’s Result vocabulary, a stale session surfaces a requires-re-authentication outcome that re-prompts for the password, and the plugin’s password argument is that re-proof for credential accounts.
  3. The response carries two things: a totpURI, an otpauth:// URI containing the secret, and backupCodes, the recovery codes in plaintext, this one time. The client renders the totpURI as a QR code, plus the raw base32 secret underneath, because some people type it by hand. Here is the part the names get wrong: enable() does not turn 2FA on. user.twoFactorEnabled is still false. Enrollment is deliberately two-phase, generating and showing now and proving and committing in step 5, so that a misconfigured authenticator can’t lock the user out of their own account before they’ve confirmed it works.
  4. The user opens their authenticator app, whether Google Authenticator, 1Password, Authy, or whatever they use, and scans the QR. The app stores the secret and starts emitting a fresh six-digit code every 30 seconds.
  5. The user types that first code back into the settings form, which calls authClient.twoFactor.verifyTotp({ code }). A match proves the secret transferred correctly, and this is the commit. It flips user.twoFactorEnabled to true and marks the row verified.
  6. Only now does the UI reveal the recovery codes for the user to save, with the line that carries the whole flow: “store these somewhere safe; you will not see them again.” The codes are stored hashed, so the plaintext you’re showing exists only in this response and is gone after the page closes. Give the user a way to download, print, or copy them, and a checkbox, “I’ve saved my recovery codes,” before they can leave.
Step 1 / 6 Phase 1 · generate twoFactorEnabled false
Security settings
Two-factor authentication Enable two-factor
The user clicks Enable two-factor in security settings — already signed in, so this runs inside a live session.
Step 2 / 6 Phase 1 · generate twoFactorEnabled false
authClient.twoFactor.enable({ password })
Re-prove the password elevation · a stale session is rejected
enable({ password }) — the password is the elevation proof. A stale or borrowed session is rejected right here.
Step 3 / 6 Phase 1 · generate twoFactorEnabled false
backupCodes (plaintext, this once)
3f9a-2b718c04-9de2a17e-44c3d0b9-1f6a +6
⚠ two-factor still OFF
The server returns the secret as a QR plus the recovery codes — but two-factor is still OFF until the code is verified.
Step 4 / 6 Phase 1 · generate twoFactorEnabled false
Authenticator 739 204
a fresh code every 30s
The user scans the QR; their authenticator app starts emitting a fresh six-digit code every 30 seconds.
Step 5 / 6 Phase 2 · commit twoFactorEnabled true
authClient.twoFactor.verifyTotp({ code })
match → verified, the flag flips
verifyTotp({ code }) matches — the commit. Now twoFactorEnabled flips to true and the row is marked verified.
Step 6 / 6 Done · save codes twoFactorEnabled true
Save your recovery codes
3f9a-2b718c04-9de2a17e-44c3d0b9-1f6a 2e5f-7a90 b3c1-08dd
You will not see these again — store them somewhere safe.
The recovery codes are revealed once, to be saved now — they are stored hashed and never shown again.

Now the code for the two calls that do the work, enable and verifyTotp, annotated so you can see exactly where the two load-bearing beats live.

async function onEnable(password: string) {
const { data } = await authClient.twoFactor.enable({ password });
setQrUri(data.totpURI);
setRecoveryCodes(data.backupCodes);
}
async function onConfirm(code: string) {
const { error } = await authClient.twoFactor.verifyTotp({ code });
if (error) return setError('That code did not match. Try the current one.');
setStep('show-recovery-codes');
}

enable({ password }): the password is the elevation proof. Without a valid one, the call fails and 2FA is never armed, so a stale or borrowed session can’t turn the tier on.

async function onEnable(password: string) {
const { data } = await authClient.twoFactor.enable({ password });
setQrUri(data.totpURI);
setRecoveryCodes(data.backupCodes);
}
async function onConfirm(code: string) {
const { error } = await authClient.twoFactor.verifyTotp({ code });
if (error) return setError('That code did not match. Try the current one.');
setStep('show-recovery-codes');
}

Destructure the QR URI and the plaintext recovery codes out of the response. This is the only moment the codes exist in plaintext, and 2FA is not on yet, even though enable succeeded.

async function onEnable(password: string) {
const { data } = await authClient.twoFactor.enable({ password });
setQrUri(data.totpURI);
setRecoveryCodes(data.backupCodes);
}
async function onConfirm(code: string) {
const { error } = await authClient.twoFactor.verifyTotp({ code });
if (error) return setError('That code did not match. Try the current one.');
setStep('show-recovery-codes');
}

verifyTotp({ code }) is the commit: a match flips twoFactorEnabled to true and marks the row verified. Note it takes only the code. It is the same call, with no token, in the sign-in section too.

async function onEnable(password: string) {
const { data } = await authClient.twoFactor.enable({ password });
setQrUri(data.totpURI);
setRecoveryCodes(data.backupCodes);
}
async function onConfirm(code: string) {
const { error } = await authClient.twoFactor.verifyTotp({ code });
if (error) return setError('That code did not match. Try the current one.');
setStep('show-recovery-codes');
}

Only after the commit do we route to the screen that reveals the recovery codes, once.

1 / 1

The recovery-code reveal is the most common place a 2FA implementation goes wrong, so it’s worth seeing the actual screen rather than just reading about it. The codes arrive in plaintext only in the enable response. Once the user navigates away, the plaintext is gone forever, and regenerating is the only way to get a new set, which replaces the old one. If your UI doesn’t surface them and insist on saving them right here, you’ve quietly handed every user a future lockout.

Save your recovery codes

Use one of these if you ever lose access to your authenticator app.

01 3f9a-2b71
02 8c04-9de2
03 a17e-44c3
04 d0b9-1f6a
05 5e82-c7b4
06 9af3-0d61
07 71c5-e2a8
08 2e5f-7a90
09 b3c1-08dd
10 6d40-9f12
Download codes Copy

These codes will not be shown again. Store them in a password manager. Each one works once if you lose access to your authenticator app.

Done
The reveal happens once. After this screen, the codes exist only as hashes in the database — there is no way to show them again.

The sign-in challenge: composing with the password step

Section titled “The sign-in challenge: composing with the password step”

Now the other moment a TOTP code gets typed. Everything that was true for enrollment is false here, which is exactly why the two get confused.

First, a short recap of where the sign-in lesson left off, because this lesson is the other end of that thread. When an account has 2FA enabled, signInEmail resolves on its success channel carrying { twoFactorRedirect: true, twoFactorMethods }. The action maps that to SignInOk as { status: 'second-factor', methods }, and the form, instead of redirecting to the dashboard, switches on that status and renders a TOTP prompt. That branch already exists, because you built the fork. This lesson fills in the prompt it routes to.

The structural difference from enrollment, stated plainly, is that there is no full session yet. The first factor passed, but Better Auth did not issue a session. Instead it set a short-lived cookie that means “first factor verified, second factor pending.” The user is in an in-between state: past the password, not yet logged in. So the gate here is not elevation and not a live session; it is that first-factor cookie.

The prompt itself is one six-digit input. On submit, it calls authClient.twoFactor.verifyTotp({ code, trustDevice }), and this is the detail to remember: there is no token argument. No factorToken, nothing carried from the password step in your code. The call rides the first-factor cookie that Better Auth already set, and in the browser authClient.twoFactor.* attaches that cookie for you. If you go looking for a token to thread through and pass into this call, you will be looking for something that does not exist. The context travels in the cookie, not in your function arguments.

The { status: 'second-factor' } branch of the sign-in form
// Rendered when the action returns SignInOk = { status: 'second-factor', methods }.
async function onSubmitCode(code: string) {
const { error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice,
});
if (error) return setError('That code is not right.');
router.push(safeNext(searchParams.get('next')));
}

This is not a new page. It is the body of the { status: 'second-factor' } branch the sign-in form already forks on. There is no token argument: verifyTotp rides the first-factor cookie, as the prose above stressed. The redirect reuses safeNext, the open-redirect guard from the sign-in lesson, called the same way on the next from the URL. It is reused, not rebuilt.

One knob on that call is worth surfacing to the user: trustDevice. When true, Better Auth remembers this device for about 30 days and won’t challenge the second factor on sign-ins from it during that window, and the window resets each time they sign in within it. In the UI this is the familiar “trust this device for 30 days” checkbox. The trade is the obvious one, convenience against the risk that a lost or shared device skips the second factor for a month, so default it checked for a consumer product and leave it unchecked for high-stakes ones.

Here is what happens on each outcome. A correct code issues a fresh session and redirects through safeNext. A wrong code returns a plain “that code is not right” error, and the verify endpoint is rate-limited, because as established the ±1 window over a million-value space makes a per-attempt cap non-negotiable. Finally, the screen needs one more affordance to be complete: a “lost your authenticator?” link. It routes to the recovery-code path, which is the next section.

Step 1 / 5 First factor verified session none
Password accepted — the first factor is verified. Better Auth sets a short-lived first-factor cookie. No session yet.
Step 2 / 5 The sign-in form continues session none
Two-factor required
The form reads { status: 'second-factor' } and renders the TOTP prompt instead of redirecting to the dashboard.
Step 3 / 5 Second factor submitted session none
authClient.twoFactor.verifyTotp({ code, trustDevice })
no factorToken context rides the first-factor cookie
verifyTotp({ code, trustDevice }) — there is no token argument. The call rides the first-factor cookie Better Auth already set.
Step 4 / 5 The code matches session
code matches — a fresh session is issued
The code matches — only now is a fresh session issued. The session pill flips from none to ✓.
Step 5 / 5 Signed in session
safeNext /dashboard
Redirect through safeNext, the open-redirect guard reused from the sign-in lesson, to the dashboard.

You now have two calls named verifyTotp that look the same and behave differently, and telling them apart is the main thing to take from this lesson. Sort each fact below into the flow it belongs to.

Each fact below belongs to exactly one of the two flows where a TOTP code gets typed. Sort them. Drag each item into the bucket it belongs to, then press Check.

Enrollment (in settings) Turning the feature on
Sign-in challenge Using it to finish logging in
The user already has a full session
Gated by re-proving the password
Returns the totpURI and recovery codes
This is where twoFactorEnabled flips to true
No full session yet — only a first-factor cookie
Triggered by requires-second-factor from the sign-in action
verifyTotp rides a cookie and takes no token
trustDevice can skip it for 30 days

Recovery codes: getting back in when the phone is gone

Section titled “Recovery codes: getting back in when the phone is gone”

A phone gets lost, wiped, or replaced, and with it the authenticator secret. Without a fallback, that user is locked out of their own account, and the second lock you added is now also a wall around the person who owns the door. Recovery codes are that fallback, and they are why the enrollment section insisted you save them.

The lost-phone path. From the sign-in TOTP prompt, “lost your authenticator?” surfaces a recovery-code input. The user types one of their ten codes and the form calls authClient.twoFactor.verifyBackupCode({ code }). The library hashes the input and looks it up against the hashed codes in the row, because as with every token in this chapter the database stores a hash, never the plaintext. On a match it issues a session, exactly like a successful TOTP. Each code is single-use : the moment it works, it is consumed and removed, so the same code can never be replayed. Like the TOTP challenge, verifyBackupCode also rides the first-factor cookie and accepts trustDevice.

A user signing in with a recovery code is one step from lockout: they’ve spent one of ten lifelines and their authenticator is gone. So don’t just let them in. After a recovery sign-in, nudge them to re-establish a factor: enroll a fresh TOTP secret on a device they still have, and review how many codes remain. Letting them through without that prompt is how a recoverable situation becomes a support ticket a week later.

Running low, or codes leaked: regenerate. authClient.twoFactor.generateBackupCodes({ password }) replaces the entire old set with a new one and returns the new codes once. Note the password: regenerating is a security-posture change, so it is elevated, the same password gate as enable. And note what it means that the old codes die: regeneration is the only way to “see” codes again, because there is no “show me my current codes” for a user. They’re hashed, so even the server can’t reproduce them. A viewBackupCodes endpoint does exist, but it is server-only and requires a fresh session, a primitive for an admin or support tool rather than a user-facing feature.

Turning 2FA off: disable, elevated. authClient.twoFactor.disable({ password }) uses the same password gate as enable, and that symmetry is the entire point: if you needed the password to add this tier, you need it to remove it. A stale or borrowed session must not be able to strip the second factor off the account. Enable and disable are the two ends of the elevation spine, and neither one trusts a live session alone.

const { error } = await authClient.twoFactor.verifyBackupCode({
code,
trustDevice,
});

Mid-sign-in, no session yet. Like verifyTotp, this rides the first-factor cookie, with no password and no token. The code is single-use: it is consumed and removed on success.

Pull the thread back to the chapter’s recurring property: recovery codes are the latest instance of the secret the user holds is not the row in the database. The password, the email-verification token, the reset token, and the magic link each live hashed at rest, and the recovery codes make five. That property is also why the dead-end is so sharp. If a user never saved their codes and then loses their phone, the only paths left are regeneration, which needs a live session they don’t have, or a support agent verifying their identity by some means outside any auth library. State it plainly, because it is the whole reason for the once-only insistence: no saved recovery codes plus a lost phone equals a support ticket at best, a lost account at worst.

Ada enabled 2FA months ago and saved her ten recovery codes. Today her phone is in a lake. Which of these hold up? Select all that apply.

She can sign in with one of the saved codes, but that code is spent the moment it works — nine remain.
Support can’t read her the codes she has now; the only way to put a readable set in front of her is to issue a brand-new one.
If she regenerates, the codes she never touched stop working too, not just the one she just used.
Since she’s locked out of her authenticator, the app can email or text her the remaining codes so she doesn’t lose access.
A “show my recovery codes” page would let her re-read the originals if she’d only thought to open it before the lake.
Once she’s back in via a recovery code, she can turn 2FA off without re-entering her password, since she just proved she’s her.

You can build it now. The senior question is when you do, because a second factor is a conditional tier, not a default-on feature, and turning it on before the threat justifies it buys you friction and a lost-phone support queue for no security gain.

The threshold is the shape of what the product protects. Enable the tier when the product handles money, admin or destructive surfaces, or other people’s data, such as a B2B app holding a tenant’s customers. For a low-stakes consumer app in its first year, password plus email verification is the baseline, and bolting on mandatory 2FA there adds a checkbox to every login and a support burden before anything warrants it. The graceful middle is to offer it opt-in to everyone early and enforce it only for privileged roles: the admin who can read every account doesn’t get a choice, while the casual user does.

Then which second factor, and this is the bridge to the next lesson. TOTP is universal: any authenticator app, any OS, no hardware, and it works offline. Its weakness, as you saw, is that it’s phishable, since a live proxy can relay the code in its window. Passkeys, the next lesson, are phishing-resistant and origin-bound: they refuse to authenticate against a look-alike domain, closing exactly that gap. They cost the user a passkey-capable device and ecosystem. The 2026 call is rarely either/or, so offer both: TOTP as the universal default, passkeys as the stronger upgrade for users who can use them. And one pattern beyond this lesson: some specific actions, like exporting data, changing billing, or transferring ownership, should demand a fresh second-factor challenge no matter how recently the user signed in. That is step-up authentication; we name it here and build the full pattern in a later chapter.

Should you add a second factor — and which?

One last line, and it is a hard rule: never default to SMS. A text-message code is the weakest common second factor, because a SIM swap hands an attacker your phone number and every code sent to it. TOTP is the floor. SMS is below it.