Every auth tutorial eventually says 'add 2FA with this library' and then links to a package with 47 transitive dependencies you'll never audit. We went down that path, got nervous about what was actually happening inside those black boxes, and decided to just implement TOTP ourselves. Turns out it's maybe 150 lines of code. Here's all of it.
How TOTP Actually Works
TOTP stands for Time-based One-Time Password and it's defined in RFC 6238. The whole thing is surprisingly simple: you share a secret between the server and the user's authenticator app (Google Authenticator, Authy, whatever). Every 30 seconds, both sides run the same HMAC-SHA1 operation on that secret plus the current time window. If the results match, the user proved they have the secret. That's it. No network call to verify. No database lookup for a code. Pure math.
The secret is usually a random 20-byte value encoded as base32 (because authenticator apps expect that format). The QR code you scan is just a URI encoding of that secret along with your app name and the user's email. Once scanned, the secret lives in the authenticator app and on your server — never transmitted again.
Generating the Secret
We'll use the Web Crypto API which is available in Node.js 18+ and in the Edge runtime. No external dependencies needed. First, let's generate a random secret and encode it as base32:
// lib/totp.ts
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function generateSecret(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let result = '';
// Base32 encodes 5 bits per character
let buffer = 0;
let bitsLeft = 0;
for (const byte of bytes) {
buffer = (buffer << 8) | byte;
bitsLeft += 8;
while (bitsLeft >= 5) {
bitsLeft -= 5;
result += BASE32_CHARS[(buffer >> bitsLeft) & 31];
}
}
if (bitsLeft > 0) {
result += BASE32_CHARS[(buffer << (5 - bitsLeft)) & 31];
}
return result;
}
export function base32Decode(input: string): Uint8Array {
const str = input.toUpperCase().replace(/=+$/, '');
const bytes: number[] = [];
let buffer = 0;
let bitsLeft = 0;
for (const char of str) {
const value = BASE32_CHARS.indexOf(char);
if (value === -1) throw new Error(`Invalid base32 character: ${char}`);
buffer = (buffer << 5) | value;
bitsLeft += 5;
if (bitsLeft >= 8) {
bitsLeft -= 8;
bytes.push((buffer >> bitsLeft) & 255);
}
}
return new Uint8Array(bytes);
}The TOTP Algorithm
Now the actual TOTP verification. We compute an HMAC-SHA1 of the current 30-second time window using the secret, then extract a 6-digit code from it using a technique called dynamic truncation. We also check the previous and next time windows to account for clock skew — users who type slowly will thank you for this.
// lib/totp.ts (continued)
async function hmacSHA1(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
function timeWindow(date = new Date()): number {
return Math.floor(date.getTime() / 1000 / 30);
}
async function generateTOTP(secret: string, counter: number): Promise<string> {
const secretBytes = base32Decode(secret);
// Counter as big-endian 8-byte buffer
const counterBuffer = new Uint8Array(8);
let c = counter;
for (let i = 7; i >= 0; i--) {
counterBuffer[i] = c & 0xff;
c = Math.floor(c / 256);
}
const hmac = await hmacSHA1(secretBytes, counterBuffer);
// Dynamic truncation
const offset = hmac[19] & 0xf;
const code = (
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)
) % 1_000_000;
return code.toString().padStart(6, '0');
}
export async function verifyTOTP(
secret: string,
token: string,
windowSize = 1 // check ±1 windows for clock skew
): Promise<boolean> {
const currentWindow = timeWindow();
for (let i = -windowSize; i <= windowSize; i++) {
const expected = await generateTOTP(secret, currentWindow + i);
// Constant-time comparison to prevent timing attacks
if (timingSafeEqual(expected, token)) return true;
}
return false;
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}The timing-safe comparison matters. A naive `a === b` can leak information about how many characters matched because JavaScript engines can short-circuit the comparison. XOR-ing every byte and checking the accumulated result takes the same time regardless of where strings diverge.
Building the Setup Flow
The setup flow has three steps: generate a secret, show it as a QR code, verify the user actually enrolled successfully. Don't skip that third step. We once shipped 2FA setup without a confirmation step and had users locked out of accounts because they thought they'd set it up but hadn't scanned the QR code properly.
For the QR code, generate an otpauth URI and render it with any QR code library. The URI format is standardized so every authenticator app understands it:
// app/api/2fa/setup/route.ts
import { generateSecret } from '@/lib/totp';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST() {
const session = await getSession();
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
// Generate fresh secret — don't save it yet, user might not complete setup
const secret = generateSecret();
// Store temporarily in session or short-lived DB record
// We store in DB with a 'pending' flag so it survives page refreshes
await db.user.update({
where: { id: session.userId },
data: { totpSecretPending: secret }
});
const appName = encodeURIComponent('YourApp');
const email = encodeURIComponent(session.email);
const otpauthUri = `otpauth://totp/${appName}:${email}?secret=${secret}&issuer=${appName}&algorithm=SHA1&digits=6&period=30`;
return Response.json({ secret, otpauthUri });
}
// app/api/2fa/verify-setup/route.ts
import { verifyTOTP } from '@/lib/totp';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const session = await getSession();
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const { token } = await request.json();
const user = await db.user.findUnique({
where: { id: session.userId },
select: { totpSecretPending: true }
});
if (!user?.totpSecretPending) {
return Response.json({ error: 'No pending 2FA setup' }, { status: 400 });
}
const valid = await verifyTOTP(user.totpSecretPending, token);
if (!valid) {
return Response.json({ error: 'Invalid code' }, { status: 400 });
}
// Confirmed — move pending secret to active
await db.user.update({
where: { id: session.userId },
data: {
totpSecret: user.totpSecretPending,
totpSecretPending: null,
totpEnabled: true
}
});
// Generate and return backup codes while we're here
const backupCodes = generateBackupCodes();
await saveHashedBackupCodes(session.userId, backupCodes);
return Response.json({ success: true, backupCodes });
}
function generateBackupCodes(count = 8): string[] {
return Array.from({ length: count }, () => {
const bytes = crypto.getRandomValues(new Uint8Array(5));
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
});
}The Login Challenge Flow
Once setup is done, modify your login flow to check whether 2FA is enabled. Don't issue a full session after password verification — issue a temporary 'awaiting 2FA' state. We do this with a short-lived JWT that only grants access to the 2FA challenge endpoint. Some people use a separate session flag, both approaches work.
The key thing is that the temporary state should expire. We use 5 minutes. If someone verifies their password and then walks away, their half-authenticated session shouldn't be valid when they return an hour later.
// app/api/auth/login/route.ts (simplified)
import { verifyPassword } from '@/lib/auth';
import { signJWT } from '@/lib/jwt';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await db.user.findUnique({ where: { email } });
if (!user || !await verifyPassword(password, user.passwordHash)) {
// Same error message whether email or password is wrong
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
if (user.totpEnabled) {
// Issue a scoped, short-lived token — not a full session
const challengeToken = await signJWT(
{ userId: user.id, purpose: '2fa-challenge' },
{ expiresIn: '5m' }
);
return Response.json({ requires2FA: true, challengeToken });
}
// No 2FA — issue full session as normal
const sessionToken = await createSession(user.id);
return Response.json({ sessionToken });
}
// app/api/auth/2fa-challenge/route.ts
import { verifyTOTP } from '@/lib/totp';
import { verifyJWT } from '@/lib/jwt';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const { challengeToken, totpToken } = await request.json();
const payload = await verifyJWT(challengeToken);
if (!payload || payload.purpose !== '2fa-challenge') {
return Response.json({ error: 'Invalid or expired challenge' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: payload.userId },
select: { totpSecret: true, totpEnabled: true }
});
if (!user?.totpEnabled || !user.totpSecret) {
return Response.json({ error: 'User does not have 2FA enabled' }, { status: 400 });
}
const valid = await verifyTOTP(user.totpSecret, totpToken);
if (!valid) {
// Consider rate limiting here — max 5 attempts per challenge
return Response.json({ error: 'Invalid code' }, { status: 401 });
}
// TOTP passed — now issue the real session
const sessionToken = await createSession(payload.userId);
return Response.json({ sessionToken });
}Backup Codes and Recovery
Don't ship 2FA without backup codes. Someone will lose their phone. They will email you at 11pm on a Friday. You will feel terrible. Backup codes are single-use codes stored as bcrypt hashes (or argon2 if you want to be fancy). When a user tries to use one, you hash it, check it against all stored hashes, and if it matches, invalidate that specific code.
- Generate 8-10 backup codes at 2FA setup time
- Show them once — user must save them
- Hash them with bcrypt before storing (they're passwords, treat them like passwords)
- Each code can only be used once — delete it after use
- Let users regenerate backup codes, but make them re-authenticate first
- Email the user when a backup code is used — could be someone else
Things That Will Bite You
A few edge cases that are worth knowing about before you hit them in production rather than after:
- Clock skew: server time drifts or user's phone is out of sync. The ±1 window we check covers most cases but some phones are badly wrong. Consider checking ±2 windows if you're seeing valid users fail.
- Token reuse: a TOTP code is valid for the entire 30-second window. Record the last used counter and reject codes from the same or earlier window to prevent replay attacks.
- Rate limiting: without it, someone can brute force all 1,000,000 possible codes in under 10 minutes. Limit to 5 attempts per 10-minute window per user.
- Disabling 2FA: require password re-verification before disabling. Never just 'are you sure?' modals — those are useless if someone has the user's session.
- Storing the secret: encrypt it at rest. If your DB leaks and the secrets are plaintext, attackers can generate valid codes. Use a KMS or at minimum AES-256 encryption with a key stored outside the DB.
We learned the rate limiting lesson not from an attack but from a user who fat-fingered their code 20 times and convinced themselves our app was broken. Same result either way — add rate limiting.
What About Passkeys and WebAuthn?
TOTP is the right call for most applications right now. It works on every phone, every user understands it, and it doesn't require HTTPS-only origins or fiddling with relying party IDs. WebAuthn/passkeys are genuinely better from a security standpoint — phishing-resistant by design — but the implementation is significantly more involved and adoption on the user side is still uneven. We're building WebAuthn support into some of our peal.dev templates but we always include TOTP as a fallback because not everyone has a hardware key or a face ID enrolled.
The good news is that if you implement 2FA the way we've shown here — secret in DB, verify on challenge, full session after both factors pass — adding passkeys later is just another verification path that leads to the same `createSession` call.
The Practical Takeaway
TOTP is one of those things that sounds like it needs a library until you actually look at what the library does. It's HMAC-SHA1, some bit manipulation, and base32 encoding. All of that is available in the Web Crypto API. The 150 lines you write yourself are lines you understand, lines you can audit, and lines that don't have a supply chain. The next time a 2FA library has a CVE, you won't be scrambling to patch it at 2am because you just won't be using it.
If you want the full working version including the React components, QR code rendering, backup code management, and rate limiting middleware, it's all wired up and ready to deploy. But if you're rolling your own — now you have everything you need.
