EncryptCodecencryptcodec
Blog/Authentication
AuthenticationMarch 12, 2026 · 8 min read

How to Add 2FA to Your App with TOTP — A Practical Implementation Guide

You shipped a login form, added bcrypt for passwords, and called it a day. Then your users started getting their accounts hijacked through credential stuffing — because someone dumped a database from a different site and 60% of your users reused passwords. TOTP-based 2FA is the fastest, most widely-supported fix for this, and it's far simpler to implement than most devs expect.

How TOTP Actually Works

TOTP (RFC 6238) is HMAC-based. The algorithm takes your shared secret and the current Unix timestamp divided by 30, runs HMAC-SHA1 on it, and extracts a 6-digit code. Both the server and the authenticator app do this independently — if they match, auth succeeds.

The critical thing to understand: the secret is shared once (during setup) and never transmitted again. After setup, authentication is purely computational on both sides.

TOTP — time-based one-time code

Step 1: Generate the Secret

Each user gets their own secret. It should be at least 160 bits (20 bytes) of cryptographically random data, base32-encoded. Don't use a UUID, don't derive it from the user ID.

const crypto = require('crypto');

// Generate 20 bytes of random data, base32-encode it
function generateTOTPSecret() {
const buffer = crypto.randomBytes(20);
// base32 alphabet
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let result = '';
let bits = 0;
let value = 0;

for (const byte of buffer) {
  value = (value << 8) | byte;
  bits += 8;
  while (bits >= 5) {
    result += base32Chars[(value >>> (bits - 5)) & 31];
    bits -= 5;
  }
}
if (bits > 0) result += base32Chars[(value << (5 - bits)) & 31];
return result;
}

// Or just use the 'otplib' package — it handles this for you
const { authenticator } = require('otplib');
const secret = authenticator.generateSecret(); // 'JBSWY3DPEHPK3PXP'

Store the secret encrypted at rest. If your database leaks and secrets are plaintext, attackers can generate valid codes forever. Use AES-256 with a key stored outside the database (environment variable or secrets manager).

Step 2: Build the QR Code URI

Authenticator apps expect a specific URI format:

otpauth://totp/{issuer}:{account}?secret={SECRET}&issuer={issuer}&algorithm=SHA1&digits=6&period=30
const { authenticator } = require('otplib');
const QRCode = require('qrcode');

async function generateQRCode(userEmail, secret, appName = 'MyApp') {
const otpauthUrl = authenticator.keyuri(userEmail, appName, secret);
// otpauthUrl: 'otpauth://totp/MyApp:user@example.com?secret=...&issuer=MyApp'

const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
return { otpauthUrl, qrCodeDataUrl };
}

// Usage
const secret = authenticator.generateSecret();
const { qrCodeDataUrl } = await generateQRCode('user@example.com', secret);
// Send qrCodeDataUrl to frontend as <img src={qrCodeDataUrl} />

Step 3: Verify the Code on Login

After the user sets up 2FA, every login requires: password check (as normal) → TOTP code check. Never skip the password step.

const { authenticator } = require('otplib');

// Allow 1 window of clock drift (±30 seconds)
authenticator.options = { window: 1 };

function verifyTOTP(secret, userProvidedCode) {
try {
  return authenticator.verify({ token: userProvidedCode, secret });
} catch {
  return false;
}
}

// In your login handler:
const isValid = verifyTOTP(user.totpSecret, req.body.code);
if (!isValid) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}

The Gotchas That Will Bite You

Code replay attacks. A valid TOTP code is good for 30 seconds. If you don't track used codes, an attacker who intercepts the code can reuse it within that window. Store the last successfully used code (or timestamp+counter) in your session or database and reject a second use within the same period.

Clock skew on your server. TOTP is time-based. If your server clock drifts even 60 seconds, all your users' codes will fail. Run NTP. This is embarrassing to debug in production at 2am.

Displaying the raw secret. Some developers show the base32 secret as text alongside the QR code (good — users need it for manual entry). But that screen is a gold mine if screenshotted. Add a warning, consider hiding it after initial setup, and absolutely don't log it.

Storing the secret in the same column as the password. If you're using per-column encryption, the TOTP secret and password hash should be separate secrets. A misconfiguration that exposes one shouldn't expose the other.

Not verifying setup before enabling. Always require the user to enter a valid code before you flip the totp_enabled = true flag on their account. If you enable it before they've scanned (e.g., you store the secret on "next" button click), they get locked out immediately.

Backup Codes

Generate 8–10 single-use backup codes at setup time. Hash them with bcrypt before storing (treat them like passwords — they're high-entropy but still sensitive). When a user enters one, check it, then delete it. Display them only once, and prompt the user to save them.

TOTP 2FA is a weekend project that meaningfully raises the cost of account takeover. The implementation is well-understood, the libraries are mature, and the UX is familiar to most users. The parts that go wrong in production are almost always: unencrypted secrets in the DB, missing code replay protection, and clock drift. Fix those three things and you've got a solid implementation.

Generate your TOTP secret keys using a cryptographically secure source — the EncryptCodec Secret Generator gives you properly formatted random secrets without any server-side storage.

Share this post

Generate a secure TOTP secret key

Free, browser-based — no signup required.

Frequently Asked Questions