Why Your Password Isn't Enough Anymore?
Passwords get reused, phished, and leaked. It's not a matter of if, it's when. And when it happens, your app pays the price even if you did everything right on your end.
The fix? Add a second layer. Something that lives on the user's phone, changes every 30 seconds, and works even without internet. That's TOTP-based 2FA, and by the end of this guide you'll have it fully wired up in your TypeScript app.
Let's build it.
Disclaimer: This article consists of pieces and excerpts from an educational conversation with Claude Sonnet 4.6
What Even Is TOTP?
TOTP stands for Time-based One-Time Password.
When a user enables 2FA on your app, your server generates a shared secret. It's basically a random string that both your server and the user's authenticator app know about. Using that secret and the current time, both sides independently calculate the same 6-digit code. The user types it in, you verify it, done.
The code refreshes every 30 seconds and is only valid for that window. So even if someone intercepts it, it's already useless by the time they try to use it.
You've probably seen this in action. That little 6-digit code ticking down in Google Authenticator or Authy? That's TOTP.
Why TOTP over SMS OTP?
SMS OTP is convenient but has real problems. SIM swap attacks are a thing, carriers can be socially engineered, and SMS simply doesn't work when you have no signal. TOTP has none of these issues. The code is generated entirely on the device, no network required.
It's also an open standard (RFC 6238), which means it works with any authenticator app including Google Authenticator, Authy, 1Password, and Bitwarden. You're not locking anyone into anything.
How It Actually Works Under the Hood
You don't need to understand cryptography to implement TOTP, but knowing the flow will save you a lot of confusion later.
Here's what happens, step by step.
1. Secret Generation
When a user enables 2FA, your server generates a random secret key. It's a short unique string tied to that user. Think of it as a shared password between your server and their authenticator app. This secret is stored on your server and never shown to the user directly.
2. QR Code
Instead of asking the user to type the secret manually, you encode it into a QR code using a standard format called otpauth URL. The user scans it with their authenticator app, and now the app has the secret stored locally on their phone.
3. Code Generation
Every 30 seconds, the authenticator app combines the secret + current timestamp and runs it through a hashing algorithm (HMAC-SHA1) to produce a 6-digit code. Your server does the exact same calculation independently.
4. Verification
When the user submits a code, your server runs the same calculation and checks if both results match. If yes, access granted. No network calls, no third-party service, just math on both ends arriving at the same answer.
💡 The key insight: Neither side is sending the code to the other. Both sides are independently calculating the same value because they share the same secret and trust the same clock.
Setting Up TOTP in a TypeScript Project
We'll be using two packages: otplib for everything TOTP-related and qrcode to generate the QR code image.
npm install otplib qrcode
npm install -D @types/qrcode
otplib ships with its own TypeScript types so no extra install needed there.
Generating a Secret
When a user wants to enable 2FA, the first thing you do is generate a secret for them on the server.
import { authenticator } from "otplib";
export const generateSecret = (userEmail: string) => {
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(userEmail, "YourAppName", secret);
return { secret, otpauthUrl };
};
generateSecret() gives you a random Base32 string. keyuri() packages it into a standard otpauth:// URL that any authenticator app understands.
Turning That URL Into a QR Code
The otpauthUrl alone isn't useful to the user. We need to render it as a scannable QR code.
import QRCode from "qrcode";
export const generateQRCode = async (otpauthUrl: string): Promise<string> => {
return await QRCode.toDataURL(otpauthUrl);
};
This returns a base64 image string that you can drop directly into an <img> tag on the frontend. No file storage needed.
const { secret, otpauthUrl } = generateSecret(user.email);
const qrCodeImage = await generateQRCode(otpauthUrl);
// Send qrCodeImage to the frontend
// Store secret temporarily until the user verifies setup
⚠️ Don't save the secret to your database yet. Wait until the user successfully scans the QR and verifies with a valid code. Otherwise you might store secrets for setups that were never completed.
The Registration Flow
This is where everything comes together for the first time. The goal is simple: let the user enable 2FA and confirm they've set it up correctly before you commit anything to the database.
Here's the full picture of what we're building.
User clicks "Enable 2FA" → Backend generates secret + QR → Frontend shows QR → User scans with authenticator app → User submits a verification code → Backend verifies it → Secret saved to DB
Step 1: Generate and Return the QR Code
The user hits "Enable 2FA" in your UI. Your backend generates a secret, creates a QR code, and sends it back. The secret goes into a temporary store like a user session or a short-lived cache, not the database yet.
Step 2: Verify the Setup
After scanning the QR, the user submits the 6-digit code shown in their authenticator app. You grab the pending secret from the session, verify the code against it, and only then save the secret to the database.
💡 Why verify before saving? If you save the secret immediately and the user never completes the scan, their account now has a 2FA secret attached that they can't use. They'll be locked out on next login. Always confirm first.
The Login Flow
The login flow with TOTP is just your existing login with one extra step added at the end.
User submits email and password. You verify credentials as usual. If the user has 2FA enabled, you don't issue a token yet. Instead you return a signal to the frontend saying a second factor is needed.
The frontend then shows a code input screen. The user opens their authenticator app, types in the 6-digit code, and submits it. Your backend grabs their stored secret, runs authenticator.verify({ token: code, secret }) and if it passes, now you issue the token and let them in.
The key thing to get right here is the in-between state. After password verification but before TOTP verification, the user is neither fully authenticated nor a stranger. A clean way to handle this is a short-lived partial auth token or a server-side session flag that says "password passed, waiting for TOTP." This prevents anyone from skipping the second step by hitting your token endpoint directly.
⚠️ If the TOTP code is wrong, don't reveal whether the password was correct or not in your error message. A generic
"Invalid credentials"keeps things safer.
Edge Cases and Things That'll Bite You
Getting the happy path working is the easy part. Here's what trips people up in production.
1. Clock Drift
TOTP codes are time-based, which means if your server's clock and the user's phone clock are even slightly out of sync, valid codes will get rejected.
otplib handles this with a tolerance window. By default it accepts the current code plus one code on either side, giving you a 90 second window. You can configure this via authenticator.options = { window: 1 }. Don't set it too high though. The wider the window, the longer a stolen code stays valid.
2. Encrypt Your Secrets
The TOTP secret stored in your database is the keys to the kingdom. If your database leaks and secrets are in plain text, attackers can generate valid codes for every user indefinitely.
Always encrypt the secret before storing it. AES-256-GCM via Node's built-in crypto module is a solid choice. Decrypt it only at the moment of verification, never log it, never expose it in API responses.
3. Replay Attacks
A valid TOTP code stays valid for 30 seconds. If someone intercepts it and uses it within that window, they're in. The fix is to mark each code as used the moment it's verified and reject any reuse within the same window. A simple cache entry with a 30 second TTL does the job.
4. Don't Skip Recovery Codes
What happens when a user loses their phone? Without a fallback, they're permanently locked out. Recovery codes are the safety net and we'll cover them properly in the next section.
💡 None of these are optional in a production app. Clock drift and encryption especially. They're quick to implement and the cost of skipping them is high.
Backup and Recovery Codes
Every 2FA implementation needs an answer to "what if I lose my phone?" Without one, your support inbox will fill up fast.
The idea is simple. When a user enables 2FA, generate a set of one-time recovery codes (typically 8-10 codes). The user saves them somewhere safe. If they ever lose access to their authenticator app, they can use one of these codes to get in instead of the TOTP code. Each code works exactly once and is gone after use.
Here's a clean way to generate them in TypeScript:
import crypto from "crypto";
export const generateRecoveryCodes = (): string[] => {
return Array.from({ length: 10 }, () =>
crypto.randomBytes(5).toString("hex").toUpperCase() // e.g. "A1B2C3D4E5"
);
};
Before saving them to the database, hash each code using bcrypt or Node's crypto module, just like you'd hash a password. Store only the hashes, never the raw codes. When the user submits a recovery code at login, hash the input and compare it against your stored hashes.
Once a code is used, delete it from the database immediately. It's a one-time key, treat it that way.
⚠️ Show the codes only once — right after the user completes 2FA setup. Make it very clear that these should be saved somewhere safe. After that screen, they're gone even from your end.
What if the user loses their phone and their recovery codes both?
It happens. Here are two approaches you can take depending on your app's security requirements.
Option 1: Recovery codes as the only self-serve escape hatch
This is the stricter approach. Recovery codes are the only way in without a phone. Once all codes are used up or the user is fully locked out, they go through a manual account recovery process with identity verification on your end. No self-serve reset. This makes 2FA truly unbypassable with just a password.
Option 2: Email-based re-enrollment link
The friendlier approach. Add a "Lost access to your authenticator?" link on the 2FA prompt. This sends a secure, time-limited link to the user's verified email address. Clicking it clears the existing TOTP secret and takes them straight into the 2FA setup flow to re-enroll with their new device.
This works safely because the email becomes the second factor in the recovery scenario. An attacker with only the password still can't do anything without inbox access. Just make sure to rate limit these recovery emails and flag repeated requests on the same account as suspicious activity.
💡 Most consumer apps go with Option 2 for the better user experience. If you're building something that handles sensitive data, Option 1 or a combination of both gives you stronger guarantees.
Wrapping Up
Here's what you've built. Your server generates a secret, shares it via QR code, the user's authenticator app takes it from there, and every login now requires a time-based code that only the user's device can produce. Clock drift is handled, secrets are encrypted, replay attacks are blocked, and recovery codes ensure nobody gets permanently locked out.
That's a production-grade TOTP setup.
What we haven't covered: WebAuthn and hardware security keys like YubiKey take this even further by eliminating shared secrets entirely. If you're building something that needs the highest level of security, those are worth exploring next.
But for the vast majority of apps, what you've built here is more than enough. 2FA isn't a feature only big companies ship. It's a few hours of work and it meaningfully raises the bar for anyone trying to get into your users' accounts.
Now go ship it.





Top comments (0)