Even a perfectly-hashed password is a single point of failure. If someone phishes it, guesses it, or finds it in a breach dump, your app is fully compromised. 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. means an attacker needs to steal two separate things, a much harder proposition.
Think of it like a safe that requires both a combination and a physical key. Knowing the combination alone gets you nothing.
How 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. factors work
The three factor categories each protect against different attack vectors:
| Factor | What it is | Example | Defeated by |
|---|---|---|---|
| Something you know | A secret you memorized | Password, PIN | Phishing, database breaches |
| Something you have | A physical item | Phone with auth app, hardware key | Physical theft |
| Something you are | A biometric | Fingerprint, Face ID | Biometric spoofing (rare) |
Most web apps combine the first two: a password plus a TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. code from your phone. Even if your password leaks, the attacker still needs physical access to your phone.
TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. implementation
How TOTP works under the hood
TOTP is elegant: during setup, your app and the authenticator app agree on a shared secret. Every 30 seconds, both sides independently compute HMAC-SHA1(secret, floor(unix_time / 30)) and display the last 6 digits. If they match, the code is valid, no network call needed.
Setup:
1. Server generates a random secret
2. Server shows QR code encoding the secret
3. User scans QR code with authenticator app
4. Both server and app now share the secret
Login:
1. User opens app → app computes code from (secret + current time)
2. User types code → server computes expected code
3. They match → authentication succeedsSetting up TOTP with speakeasy
npm install speakeasy qrcodeimport speakeasy from 'speakeasy';
import QRCode from 'qrcode';
// Step 1: Generate secret and send QR code to user
app.post('/auth/2fa/setup', requireAuth, async (req, res) => {
const secret = speakeasy.generateSecret({
name: 'MyApp', // Displayed in the authenticator app
length: 32
});
// Save temporarily until the user confirms it works
await db.setTemp2FASecret(req.user.id, secret.base32);
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // For manual entry if QR scan fails
qrCode: qrCodeUrl
});
});
// Step 2: User scans, enters a code to confirm setup works
app.post('/auth/2fa/verify', requireAuth, async (req, res) => {
const { token } = req.body;
const secret = await db.getTemp2FASecret(req.user.id);
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 1 // Accept codes from ±30 seconds to handle clock drift
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code - try again' });
}
await db.enable2FA(req.user.id, secret);
const backupCodes = generateBackupCodes();
await db.saveBackupCodes(req.user.id, backupCodes);
res.json({
message: '2FA enabled',
backupCodes // Show exactly once - user must save these
});
});Handling backup codes
Backup codes are single-use fallback codes for when a user loses their device. Hash them before storage:
function generateBackupCodes() {
const codes = [];
for (let i = 0; i < 10; i++) {
const code = Array(3).fill(0)
.map(() => Math.random().toString(36).substring(2, 6).toUpperCase())
.join('-');
codes.push(code);
}
return codes;
}
async function verifyBackupCode(userId, code) {
const storedCodes = await db.getBackupCodes(userId);
const match = storedCodes.find(c => bcrypt.compareSync(code, c.hash));
if (match) {
await db.removeBackupCode(userId, match.id); // One-time use
return true;
}
return false;
}Login flow with 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you.
The login endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. needs to handle both the password step and the 2FA step. A common pattern returns a partialAuth state that tells the frontend to show the 2FA input:
app.post('/login', async (req, res) => {
const { email, password, twoFactorCode } = req.body;
// Step 1: verify credentials
const user = await db.getUserByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Step 2: if 2FA enabled, verify the code
if (user.twoFactorEnabled) {
if (!twoFactorCode) {
return res.json({
partialAuth: true,
message: '2FA code required'
});
}
const totpValid = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: twoFactorCode,
window: 1
});
const backupValid = !totpValid && await verifyBackupCode(user.id, twoFactorCode);
if (!totpValid && !backupValid) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
}
// Step 3: complete login
const token = generateToken(user);
res.json({ token });
}); parameter in speakeasy.totp.verify` allows codes from one step before or after the current 30-second window. This handles clock drift between the user's device and your server, without it, users on slightly out-of-sync clocks will see valid codes rejected.SMS 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you.
SMS is easier to set up for users who don't want an authenticator app, but it's meaningfully less secure:
import twilio from 'twilio';
const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
app.post('/auth/2fa/sms/send', requireAuth, async (req, res) => {
const code = Math.floor(100000 + Math.random() * 900000).toString();
await db.saveSMSCode(req.user.id, {
code: await bcrypt.hash(code, 10),
expiresAt: Date.now() + 10 * 60 * 1000 // 10 minutes
});
await client.messages.create({
body: `Your verification code: ${code}`,
from: process.env.TWILIO_PHONE,
to: req.user.phoneNumber
});
res.json({ message: 'Code sent' });
});SMS 2FA is vulnerable to SIM-swapping (attacker convinces your carrier to port your number) and SS7 attacks (carrier infrastructure vulnerabilities). For high-security applications, require TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. or hardware keys. For general consumer apps, SMS is acceptable and much better than no 2FA at all.
Hardening your 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you. implementation
Rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed. 2FA attempts
An attacker who obtains a password will try to brute-force the 6-digit code (1,000,000 possibilities). Rate limiting makes this infeasible:
const twoFALimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many 2FA attempts - try again in 15 minutes'
});
app.post('/login', twoFALimiter, loginHandler);Encrypting stored secrets
If your database is breached, you don't want 2FA secrets exposed alongside password hashes:
import crypto from 'crypto';
function encryptSecret(secret) {
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(secret, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
encrypted,
iv: iv.toString('hex'),
authTag: cipher.getAuthTag().toString('hex')
};
}Quick reference
| Method | Security | Usability | Recommended for |
|---|---|---|---|
| TOTP (authenticator app) | High | Medium | Most apps |
| Hardware key (WebAuthn) | Highest | Low | Enterprise, high-value accounts |
| SMS | Medium | High | Consumer apps, fallback |
| Email OTP | Medium-low | High | Low-risk apps only |
npm install speakeasy qrcodeimport speakeasy from 'speakeasy';
// Generate secret
const secret = speakeasy.generateSecret({ name: 'MyApp' });
// Get QR code URL
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
// Verify a token at login time
const verified = speakeasy.totp.verify({
secret: userSecret,
encoding: 'base32',
token: userInput,
window: 1
});