Two-Factor Authentication#
Overview#
Two-factor authentication (2FA) in better-auth is a plugin (twoFactor) that intercepts sign-in flows for email, username, and phone-number credentials and replaces the standard session cookie with a short-lived challenge cookie until the second factor is verified. The plugin lives in packages/better-auth/src/plugins/two-factor/ and supports TOTP, OTP, and backup codes as second factors.
2FA Challenge Cookie Lifecycle#
1. Cookie Creation (Sign-In Intercept)#
When a user with twoFactorEnabled: true signs in, the after hook fires for /sign-in/email, /sign-in/username, and /sign-in/phone-number . It:
- Deletes the just-created session and its cookie — preventing a credential-only session from leaking while 2FA is pending.
- Creates a signed
two_factorcookie (name:"two_factor") containing a random identifier (e.g.,2fa-<random>) with a 10-minute max-age by default, configurable viaoptions.twoFactorCookieMaxAge. - Writes two verification records to the DB: one mapping the identifier →
userId, and a second per-challenge attempt counter (2fa-attempts-<identifier>→"0") with matching expiry . - Returns
{ twoFactorRedirect: true, twoFactorMethods: [...] }to the client instead of a session .
The cookie is HMAC-signed via ctx.setSignedCookie using the app's secret, so the server can detect tampering before touching the database.
2. HMAC Validation (Verification Requests)#
verifyTwoFactor(ctx) is the central gatekeeper called by every factor handler (TOTP, OTP, backup codes). On entry it:
- Reads the signed
two_factorcookie viactx.getSignedCookie(...)— an invalid signature returnsnullimmediately . - Looks up the verification record by the cookie value to confirm it still exists and hasn't expired .
- Returns a context object with
valid(),invalid(),beginAttempt(), and the resolvedsession/user.
If a live session already exists (re-verification, e.g., for disableTwoFactor), the DB lookup is skipped entirely and the session is used directly .
3. Atomic Token Consumption#
valid() calls consumeVerificationValue(signedTwoFactorCookie) before issuing a session . The consume operation is atomic — it deletes the row and returns it. Only the first caller wins; a concurrent request or replay gets null and is rejected. After the race gate is passed, the real session is created and setSessionCookie is called . The 2FA cookie is then always expired unconditionally — a fix from PR #6604 .
4. Per-Challenge Attempt Counting#
beginAttempt(allowedAttempts) uses the same atomic consume pattern on the attempt-counter row. On each verification attempt:
- The counter row is consumed and its value read.
- If attempts ≥ budget (default: 5 — ), the challenge and cookie are canceled.
- A new counter row is immediately re-created with an incremented value (fail) or the same value (server error restore).
Default per-challenge budget: 5 attempts .
Account-Level Lockout#
assertTwoFactorNotLocked, recordTwoFactorFailure, and resetTwoFactorFailures in verify-two-factor.ts implement a NIST SP 800-63B §5.2.2-compliant lockout across challenges and factors:
| Constant | Default |
|---|---|
DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS | 10 |
DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS | 900 s (15 min) |
Failed counts are incremented atomically in the twoFactor table; the lock is cleared lazily on the next request once expired .
Trust Device Cookie#
When trustDevice: true is sent on verification, valid() generates a trust token :
- Creates a random
trust-device-<32 chars>identifier. - Signs it as
HMAC-SHA-256("base64urlnopad", secret, userId + "!" + identifier). - Persists a verification record with the identifier →
userIdand expiry (default 30 days — ). - Sets a signed
trust_devicecookie containingtoken!identifier.
On subsequent sign-ins, the after-hook re-validates the HMAC, then verifies the server-side record, rotates the identifier, and skips the 2FA challenge . On disableTwoFactor, the trust record is deleted and the cookie expired .
Security Hardening Fixes#
Session Cookie Leakage on 2FA Response (PR #9639)#
Before this fix, the credential sign-in handler would write valid session_token / session_data cookies and the 2FA hook would only append expiring overrides. The response therefore contained both a valid and an expiring Set-Cookie for the same name. Proxies, logs, or SDKs reading the raw response could replay the valid cookie value, bypassing the 2FA gate .
Fix: expireCookie(ctx, cookie) now calls removeSetCookieEntries(ctx, cookieName) first, which scrubs all prior Set-Cookie entries (including chunked variants like ${name}.0) from both ctx.responseHeaders and ctx.context.responseHeaders before appending the expiry entry .
Disable 2FA Requires DB-Backed Session#
/two-factor/disable was switched from sessionMiddleware to sensitiveSessionMiddleware , requiring a live database session. This blocks stale cookie-cache payloads from authorizing the disable action .
Token Not Deleted After TOTP Verification (PR #6604)#
Verification tokens were not deleted after successful verification, causing table bloat and leaving tokens reusable until natural expiry. The fix ensures consumeVerificationValue (atomic delete-and-return) is always called in valid(), and the 2FA cookie is cleared unconditionally .
Cookie Parsing: convertSetCookieToCookie (Server-Side API Chains)#
When calling auth.api.* endpoints server-side in a chain (e.g., test harnesses or middleware), the response from one call contains Set-Cookie headers but the next call needs Cookie headers. The utility convertSetCookieToCookie(headers) in packages/better-auth/src/test-utils/headers.ts converts response headers to request headers by calling applySetCookies, which merges each Set-Cookie value into the Cookie header.
PR #9543 centralized and hardened cookie parsing in cookie-utils.ts to tolerate ; separators without a trailing space — a common proxy/runtime stripping pattern — preventing users from appearing logged out behind AWS Lambda + API Gateway and similar environments.
Key Files#
| File | Purpose |
|---|---|
plugins/two-factor/index.ts | Plugin entrypoint: endpoints, sign-in hook, cookie creation |
plugins/two-factor/verify-two-factor.ts | Core verification logic, atomic consumption, lockout |
plugins/two-factor/constant.ts | Cookie names, max-ages, default attempt/lockout limits |
test-utils/headers.ts | convertSetCookieToCookie for server-side API chains |