TOTP two-factor authentication in SvelteKit

Two-factor authentication is the feature that quietly decides whether a B2B buyer's security review passes. It is also the feature most starter kits leave out, because the "just integrate Auth0" answer is a vendor subscription and a redirect dance. The pattern below is the other path: about 260 lines, no external dependency, and a storage model you can audit in one sitting.

This page describes the implementation that ships in the SvelteKit starters — src/lib/server/services/mfa.ts. It is the same file in the SQLite, Postgres and Supabase variants; only the database handle differs.

The state model: two flags, not one

Almost every bug in hand-rolled MFA comes from collapsing enrolment and activation into one step. Here the user row carries a secret (totpSecret) and an activation flag (totpEnabled) separately:

  • generateTotpSecret() writes the secret and returns theotpauth:// URI. MFA is not on yet. The user scans the QR code; nothing about their login has changed.
  • enableMfa(code) verifies one live code and only then setstotpEnabled. A user who scans the QR code and walks away is not locked out of their own account by a half-finished enrolment.

The alternative — writing the secret and flipping the flag in one call — locks out every user whose authenticator app failed to save, which is a support ticket you cannot fix from the server side.

The verifier: RFC 4226 truncation, ±1 window

The verifier is a direct implementation of the HOTP construction inside TOTP: HMAC-SHA1 over the 8-byte big-endian time counter, dynamic truncation using the low nibble of the last byte as an offset, then% 1000000 and zero-padding to six digits.

export function verifyTotp(secret: string, code: string): boolean {
  const key = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
  // Check current and adjacent time windows (±1)
  for (let offset = -1; offset <= 1; offset++) {
    const time = Math.floor((now + offset * 30) / 30);
    const timeBuffer = Buffer.alloc(8);
    timeBuffer.writeBigInt64BE(BigInt(time));
    const hmac = createHmac('sha1', key).update(timeBuffer).digest();
    const otpOffset = hmac[hmac.length - 1] & 0x0f;
    const otp = ((hmac[otpOffset] & 0x7f) << 24) |
      ((hmac[otpOffset + 1] & 0xff) << 16) |
      ((hmac[otpOffset + 2] & 0xff) << 8) |
      (hmac[otpOffset + 3] & 0xff);
    const code6 = (otp % 1000000).toString().padStart(6, '0');
    if (code6 === code) return true;
  }
  return false;
}

The offset loop is the part worth reading twice. Accepting only the exact current window breaks every user whose phone clock drifts a few seconds; accepting five windows either side turns a six-digit code into something reusable for minutes. One window either side is the standard compromise, and it is what Google, GitHub and every authenticator app expect.

Recovery codes: ten, hashed, single-use

Recovery codes exist because phones get lost. They are also the weakest link in most implementations, because they are usually stored in plain text and never marked used. Here:

  • Ten codes are generated at activation, eight hexadecimal characters each, and returned once — fromenableMfa() and nowhere else.
  • Only sha256(code) is stored inmfaRecoveryCodes.codeHash. A database dump does not hand the attacker working codes.
  • Redeeming one writes usedAtMs on that row, andverifyMfa() skips any code with a timestamp. A recovery code is single-use by construction rather than by convention.
  • disableMfa() deletes the secret and every recovery code together. Turning MFA off and on again produces a fresh set; the old one is gone.

Wiring it into the login flow

// 1. Create a secret, store it unverified, hand back the otpauth:// URI.
const { secret, uri } = await generateTotpSecret(db, userId);
//    uri is otpauth://totp/VerdantStack:<email>?secret=…&algorithm=SHA1&digits=6&period=30
//    Render it as a QR code; the raw secret is for manual entry only.

// 2. The user types one code from their authenticator. Only now does MFA
//    turn on — and this is the only call that returns recovery codes.
const recoveryCodes = await enableMfa(db, userId, codeFromUser); // string[10]

// 3. On every later login, after the password check:
const ok = await verifyMfa(db, userId, code);
//    Accepts a live TOTP code OR an unused recovery code (which is consumed).
if (!ok) return fail(401, { message: 'Invalid code.' });

The placement matters more than the code. verifyMfa() runs after the password check succeeds and before the session is created — never after. If you create the session first and then ask for the code, an attacker with a stolen password already holds a valid session cookie and the second factor is decoration.

The same ordering applies to the audit trail: themfa.enabled and mfa.disabled rows are written by the service, so they land whether the action is reached from a settings page, a support tool, or a test.

Honest scope: what this is not

  • TOTP only. No WebAuthn, no passkeys, no SMS. TOTP is the factor that works with every authenticator app and needs no vendor; passkeys are a different (and better) primitive that this kit does not ship.
  • No rate limiting on code entry inside the service. A six-digit code has 106 possibilities, which is only safe if guesses are throttled — wire the same sliding-window limiter used for login around verifyMfa(), keyed per user.
  • The comparison is not constant-time. verifyTotp() compares strings with ===. With a 30-second window and throttled attempts the practical exposure is negligible, but if your threat model includes a co-located attacker with fine-grained timing measurements, swap in a constant-time compare.
  • No "remember this device" cookie. Every login asks for a code. That is a deliberate default: a device-trust cookie is a second long-lived credential to secure, and most SaaS products add it later, not first.

Production checklist

  • The secret is stored on the user row and is only trusted after the user proves one working code — enrolment and activation are two separate writes, never one.
  • Verification accepts the current 30-second window plus one window either side, so a phone whose clock is 20 seconds fast still works without widening the window to the point where a stolen code stays valid.
  • Ten recovery codes are generated at activation, hashed with SHA-256 before storage, and marked used the moment one is redeemed — a recovery code cannot be replayed.
  • Disabling MFA deletes the secret and every recovery code in the same operation, so "turn it off and on again" cannot leave orphaned codes behind that still work.
  • Every state change writes an audit row (`mfa.enabled`, `mfa.disabled`) — MFA is a security control, so its history has to be answerable after the fact.
  • No read path returns the secret or a working recovery code: both are handed back exactly once, from the call that creates them, and every later read sees only hashes.

Related guides: Hashed session tokens · Rate limiting login · Multi-tenant auth architecture

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.