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 charges cards now, and its admin console lets a support agent read any customer’s data. A password is something the person knows: a single lock on both doors that fails the moment it leaks, gets reused on a breached site, or is typed into a convincing fake. Surfaces that touch money or other people’s data need a second, independent lock, a factor the attacker must steal separately. The standard choice is a six-digit code from an authenticator app, called TOTP: it works with every authenticator on every OS, offline, with no hardware to buy and no SMS to intercept. Sign-in already returns requires-second-factor with the available methods, then stops. This lesson builds what happens next, in four beats: enroll the factor, challenge for the code at sign-in, let the user recover when their phone falls in a lake, and give them control to manage it.

A six-digit number sounds flimsy; here is why it holds.

At setup, the server and your authenticator app agree on one shared secret : a long random value, around 160 bits, written in base32 . It is generated once, stored on both ends, and never travels again.

Both sides then compute codes independently, in parallel: take the secret, take the current time chopped into 30-second windows, feed both through HMAC , and truncate to six digits. The same secret plus the same window yields the same six digits on your phone and on the server, so a match proves you hold the secret without either side sending it. This is TOTP .

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, and you might type a code seconds before it rolls over, so the server accepts the current window plus one on either side, about 90 seconds, to absorb that clock skew . That widened ±1 window is why the verify endpoint must be rate-limited: six digits is only a million possibilities, brute-forceable without a cap on guesses. Better Auth caps the attempts; we name the wiring at the call site and build it in a later chapter.

Second, what TOTP does not defeat. It stops the attacks passwords lose to: a leaked database, a reused password, credential stuffing . The attacker holds the password and still can’t get in. But it does not stop real-time phishing: a proxy page that mimics your login captures the six digits as you type and replays them inside the 90-second window. Passkeys close that gap, the subject of the next lesson, because they bind to the real site’s origin and a look-alike domain can’t relay anything. One last placement: an authenticator-app code is the floor for a second factor. SMS is weaker, since an attacker can hijack your number through a SIM swap, so skip it.

This is the magic-link pattern again: two registrations and a schema regeneration. Better Auth ships TOTP as the twoFactor plugin, with a server half in lib/auth.ts and a client half in lib/auth-client.ts.

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.

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 your product name. totpOptions here just restates the RFC 6238 defaults (period: 30, digits: 6, SHA1); every authenticator expects them, so keep them.

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. Forget it and authClient.twoFactor.* is undefined, the same trap magic links had.

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 redirects the browser to a standalone /sign-in/two-factor page when a sign-in needs the second factor. The course skips it: the sign-in action already catches the same { status: 'second-factor', methods } signal and renders the prompt inline.

1 / 1

Now regenerate the database tables with the same @better-auth/cli generate workflow you ran for other plugins: the plugin declares its columns and the CLI writes them into your Drizzle schema for a migration.

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.

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.

The secret grants the second factor only, not the password, so it sits a notch below account.password in blast radius, but it is still sensitive. Better Auth stores it server-side and the codes hashed. For an elevated-risk product, the reach is to encrypt the secret column at rest with a key from a KMS , so a leaked database dump never surfaces it.

A TOTP code gets typed at two moments that look alike: enrollment, which turns the feature on, and the sign-in challenge, which uses it to finish logging in. This section covers enrollment; the next covers the challenge.

Enrollment happens at a settings page like /settings/security/2fa, while the user is already signed in. The gate can’t be “are you logged in?”, so it is elevation: re-proving the password. Enabling 2FA, or later disabling it, demands the password again right now, regardless of how the session began, so a session you didn’t open yourself can’t change the account’s security posture.

Walk the six steps:

  1. The user clicks “Enable two-factor” on the security settings page.
  2. The action calls authClient.twoFactor.enable({ password }), where password is the elevation proof. In the course’s Result vocabulary, this argument is the requires-re-authentication re-proof for credential accounts.
  3. The response carries a totpURI (an otpauth:// URI holding the secret) and backupCodes (the recovery codes in plaintext, this one time). The client renders the totpURI as a QR code with the raw base32 secret underneath. Crucially, enable() does not turn 2FA on; user.twoFactorEnabled is still false. Enrollment is deliberately two-phase, generating now and committing in step 5, so a misconfigured authenticator can’t lock the user out.
  4. The user scans the QR with their authenticator app, which stores the secret and emits a fresh six-digit code every 30 seconds.
  5. The user types that first code back, which calls authClient.twoFactor.verifyTotp({ code }). A match proves the secret transferred and commits the change: it flips user.twoFactorEnabled to true and marks the row verified.
  6. Only now does the UI reveal the recovery codes, with the line that carries the flow: “store these somewhere safe; you will not see them again.” The codes are stored hashed, so the plaintext exists only in this response. Give the user a way to download, print, or copy them, plus an “I’ve saved my recovery codes” checkbox 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.

Here are the two calls that do the work, enable and verifyTotp, annotated.

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');
}

The password is the elevation proof. Without a valid one the call fails and 2FA is never armed.

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');
}

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 }) commits: a match flips twoFactorEnabled to true and marks the row verified. It takes only the code, no token, the same call used at sign-in.

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

1 / 1

The recovery-code reveal is where 2FA implementations most often go wrong. The plaintext codes exist only in the enable response; once the user navigates away they are gone, and the only way to get a new set is to regenerate, which replaces the old one. If your UI doesn’t surface them and insist on saving them right here, you’ve 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.

Now the other moment a TOTP code gets typed. Everything that held during enrollment is reversed here, which is why the two get confused.

Recall where the sign-in lesson left off. When an account has 2FA enabled, signInEmail resolves on its success channel with { twoFactorRedirect: true, twoFactorMethods }, which the action maps to SignInOk as { status: 'second-factor', methods }. The form switches on that status and, instead of redirecting to the dashboard, renders a TOTP prompt. You built that fork already; this lesson fills in the prompt.

The structural difference from enrollment is that there is no full session yet. The first factor passed, but Better Auth issued no session, only a short-lived cookie meaning “first factor verified, second factor pending.” The user is past the password but 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 the detail to remember is that there is no token argument. The call rides the first-factor cookie Better Auth already set, and in the browser authClient.twoFactor.* attaches that cookie for you. 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')));
}

The redirect reuses safeNext, the open-redirect guard from the sign-in lesson, called on the next from the URL.

One knob on that call is worth surfacing to the user: trustDevice. When true, Better Auth remembers this device for about 30 days and skips the second factor on sign-ins from it, with the window resetting on each sign-in. In the UI this is the familiar “trust this device for 30 days” checkbox. The trade is convenience against a lost or shared device skipping the second factor for a month, so default it checked for a consumer product and unchecked for high-stakes ones.

A correct code issues a fresh session and redirects through safeNext; a wrong code returns a plain “that code is not right” error. One affordance completes the screen: a “lost your authenticator?” link, routing to the recovery-code path covered next.

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; telling them apart is the main thing to take from this lesson.

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 or replaced, and the authenticator secret goes with it. Without a fallback, the second lock now locks out the account’s own owner. Recovery codes are that fallback, which is why enrollment insisted you save them.

The lost-phone path. From the sign-in TOTP prompt, “lost your authenticator?” opens a recovery-code input. The user types one of their ten codes and the form calls authClient.twoFactor.verifyBackupCode({ code }), which issues a session on a match, exactly like a successful TOTP. Each code is single-use , and like the TOTP challenge, verifyBackupCode rides the first-factor cookie and accepts trustDevice.

A recovery sign-in leaves the user one step from lockout: one of ten codes spent, authenticator gone. So prompt them to re-establish a factor right away, enrolling a fresh TOTP secret on a device they still have, and show how many codes remain.

Running low, or codes leaked: regenerate. authClient.twoFactor.generateBackupCodes({ password }) replaces the whole old set with a new one and returns it once. The password makes this elevated, the same gate as enable, and regeneration is the only way to see codes again, since the stored codes are hashed and even the server can’t reproduce them.

Turning 2FA off: disable, elevated. authClient.twoFactor.disable({ password }) uses the same password gate as enable: a stale or borrowed session must not be able to strip the second factor off the account.

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 consumed on success.

Like the password, the reset token, and the magic link before them, recovery codes live hashed at rest, so the dead-end is sharp. A user who never saved their codes and then loses their phone has only two paths left: regeneration, which needs a live session they don’t have, or a support agent verifying their identity outside any auth library. 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.

A second factor is a conditional tier, not a default-on feature; the senior question is when to turn it on. Enable it before the threat justifies it and you buy friction and a lost-phone support queue for no security gain.

The threshold is the shape of what the product protects: turn the tier on 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. So offer it opt-in to everyone and enforce it only for privileged roles: the admin who can read every account doesn’t get a choice; the casual user does.

Then, which factor. TOTP is universal: any authenticator app, any OS, no hardware, works offline. But it’s phishable, since a live proxy can relay the code within its window. Passkeys (next lesson) are phishing-resistant and origin-bound, closing exactly that gap, at the cost of a passkey-capable device. Rarely is it either/or, so offer both: TOTP as the universal default, passkeys as the stronger upgrade. Some actions, like changing billing or transferring ownership, should also demand a fresh second-factor challenge regardless of how recently the user signed in: step-up authentication, built in a later chapter.

Should you add a second factor — and which?

Never default to SMS. A text-message code is the weakest common second factor: a SIM swap hands an attacker your number and every code sent to it. TOTP is the floor; SMS sits below it.