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.
How TOTP proves possession
Section titled “How TOTP proves possession”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.
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: 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.
Installing the two-factor plugin
Section titled “Installing the two-factor plugin”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.
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 in lib/auth.ts. This is one of the two registrations; the other lands on the client.
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 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.
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 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.
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 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.
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
twoFactorEnabled boolean new the on/off switch the app reads 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:
- The user clicks “Enable two-factor” on the security settings page.
- The action calls
authClient.twoFactor.enable({ password }). Thatpasswordis the elevation proof. In the course’sResultvocabulary, a stale session surfaces arequires-re-authenticationoutcome that re-prompts for the password, and the plugin’spasswordargument is that re-proof for credential accounts. - The response carries two things: a
totpURI, anotpauth://URI containing the secret, andbackupCodes, the recovery codes in plaintext, this one time. The client renders thetotpURIas 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.twoFactorEnabledis 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. - 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.
- 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 flipsuser.twoFactorEnabledto true and marks the rowverified. - 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.
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 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.
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.
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: 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.
// 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.
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, 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.
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, 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.
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 the new set, plaintext this once, then hashed at rest.
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.
Should this product ship TOTP at all?
Section titled “Should this product ship TOTP at all?”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.
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.
Passkeys are phishing-resistant and origin-bound for the high-value path. Keep TOTP so users without a passkey-capable device still get a second factor.
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.
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.