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.
How TOTP proves possession
Section titled “How TOTP proves possession”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 .
JBSWY3DPEHPK3PXP T = floor(now / 30s) HMAC(secret, T) truncate 739 204 HMAC(secret, T) truncate 739 204 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.
Installing the two-factor plugin
Section titled “Installing the two-factor plugin”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.
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({ plugins: [ twoFactor({ issuer: 'Acme', totpOptions: { period: 30, digits: 6 }, }), ],});
// lib/auth-client.tsimport { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({ plugins: [ twoFactorClient(), ],});The server half: import twoFactor and add it to the plugins array.
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({ plugins: [ twoFactor({ issuer: 'Acme', totpOptions: { period: 30, digits: 6 }, }), ],});
// lib/auth-client.tsimport { 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.
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({ plugins: [ twoFactor({ issuer: 'Acme', totpOptions: { period: 30, digits: 6 }, }), ],});
// lib/auth-client.tsimport { 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.
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({ plugins: [ twoFactor({ issuer: 'Acme', totpOptions: { period: 30, digits: 6 }, }), ],});
// lib/auth-client.tsimport { 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.
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
twoFactorEnabled boolean new the on/off switch the app reads 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.
Enrolling a device
Section titled “Enrolling a device”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:
- The user clicks “Enable two-factor” on the security settings page.
- The action calls
authClient.twoFactor.enable({ password }), wherepasswordis the elevation proof. In the course’sResultvocabulary, this argument is therequires-re-authenticationre-proof for credential accounts. - The response carries a
totpURI(anotpauth://URI holding the secret) andbackupCodes(the recovery codes in plaintext, this one time). The client renders thetotpURIas a QR code with the raw base32 secret underneath. Crucially,enable()does not turn 2FA on;user.twoFactorEnabledis still false. Enrollment is deliberately two-phase, generating now and committing in step 5, so a misconfigured authenticator can’t lock the user out. - The user scans the QR with their authenticator app, which stores the secret and emits a fresh six-digit code every 30 seconds.
- The user types that first code back, which calls
authClient.twoFactor.verifyTotp({ code }). A match proves the secret transferred and commits the change: it flipsuser.twoFactorEnabledto true and marks the rowverified. - 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.
twoFactorEnabled false twoFactorEnabled false
authClient.twoFactor.enable({ password })
twoFactorEnabled false 3f9a-2b718c04-9de2a17e-44c3d0b9-1f6a +6 twoFactorEnabled false twoFactorEnabled true
authClient.twoFactor.verifyTotp({ code })
verified, the flag flips
twoFactorEnabled true 3f9a-2b718c04-9de2a17e-44c3d0b9-1f6a 2e5f-7a90 b3c1-08dd 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.
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.
3f9a-2b71 8c04-9de2 a17e-44c3 d0b9-1f6a 5e82-c7b4 9af3-0d61 71c5-e2a8 2e5f-7a90 b3c1-08dd 6d40-9f12 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.
The sign-in challenge
Section titled “The sign-in challenge”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.
// 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.
session none session none session none
authClient.twoFactor.verifyTotp({ code, trustDevice })
session ✓ session ✓ safeNext /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.
totpURI and recovery codestwoFactorEnabled flips to truerequires-second-factor from the sign-in actionverifyTotp rides a cookie and takes no tokentrustDevice can skip it for 30 daysRecovery 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.
const { data } = await authClient.twoFactor.generateBackupCodes({ password,});const freshCodes = data.backupCodes;Inside a live session, elevated by the password. Replaces the old set entirely, so every old code dies. data.backupCodes is plaintext this once, then hashed at rest.
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.
Should this product ship TOTP at all?
Section titled “Should this product ship TOTP at all?”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.
Offer TOTP opt-in later. Don’t impose a factor, and its lost-phone support queue, before the threat justifies it.
Universal, no hardware, works offline. Let regular users opt in; require it for privileged roles.
Phishing-resistant and origin-bound for the high-value path. Keep TOTP so users without a passkey-capable device still get a second factor.
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.
External resources
Section titled “External resources”The plugin reference: enable, verifyTotp, verifyBackupCode, generateBackupCodes, totpOptions.
Session lifetime and the freshness window behind the elevation gate.
A readable walk through the shared-secret + time-window + HMAC mechanism (RFC 6238).
Where each factor sits on the strength ladder, and why SMS is the floor to avoid.