DocumentsBetter Auth
Two-Factor Authentication
Two-Factor Authentication
Type
Topic
Status
Published
Created
Jul 7, 2026
Updated
Jul 7, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

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.


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:

  1. Deletes the just-created session and its cookie — preventing a credential-only session from leaking while 2FA is pending.
  2. Creates a signed two_factor cookie (name: "two_factor") containing a random identifier (e.g., 2fa-<random>) with a 10-minute max-age by default, configurable via options.twoFactorCookieMaxAge .
  3. 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 .
  4. 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_factor cookie via ctx.getSignedCookie(...) — an invalid signature returns null immediately .
  • 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 resolved session/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:

ConstantDefault
DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS10
DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS900 s (15 min)

Failed counts are incremented atomically in the twoFactor table; the lock is cleared lazily on the next request once expired .


When trustDevice: true is sent on verification, valid() generates a trust token :

  1. Creates a random trust-device-<32 chars> identifier.
  2. Signs it as HMAC-SHA-256("base64urlnopad", secret, userId + "!" + identifier).
  3. Persists a verification record with the identifier → userId and expiry (default 30 days — ).
  4. Sets a signed trust_device cookie containing token!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#

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 .


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#

FilePurpose
plugins/two-factor/index.tsPlugin entrypoint: endpoints, sign-in hook, cookie creation
plugins/two-factor/verify-two-factor.tsCore verification logic, atomic consumption, lockout
plugins/two-factor/constant.tsCookie names, max-ages, default attempt/lockout limits
test-utils/headers.tsconvertSetCookieToCookie for server-side API chains
Two-Factor Authentication | Dosu