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 — from
enableMfa()and nowhere else. - Only
sha256(code)is stored inmfaRecoveryCodes.codeHash. A database dump does not hand the attacker working codes. - Redeeming one writes
usedAtMson 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
- API keys and webhook endpoints — the other half of programmatic access
- Append-only audit log — where
mfa.enabledlands - Hashed Session Auth — DB-backed sessions with instant revocation
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.