Securing a Node.js Admin with TOTP Two-Factor Authentication

How I added authenticator-app sign-in to an Express admin: a two-step login with a short-lived ticket, codes that can only be used once, secrets encrypted at rest with AES-256-GCM, and recovery codes stored as keyed hashes.

Cover for the article Securing a Node.js Admin with TOTP Two-Factor Authentication

An admin panel is the most valuable target on a content site: whoever gets in can change every page. A strong password helps, but passwords leak. Time-based one-time passwords (TOTP, RFC 6238), the six-digit codes from Google Authenticator, 1Password or Authy, are a cheap and well-understood second factor. Here is how I implemented them in the Express API behind this portfolio, including the details that are easy to get wrong.

Sign-in becomes two steps

With MFA enabled, a correct password must not produce a session. Instead the login endpoint returns a short-lived ticket that is only good for the second step:

if (user.mfaEnabled) return { mfaRequired: true, mfaToken: signMfaToken(user) };

export function signMfaToken(user: { id: string; tokenVersion: number }): string {
  return jwt.sign({ purpose: 'mfa', tv: user.tokenVersion }, env.JWT_SECRET, {
    algorithm: 'HS256',
    audience: 'admin-mfa', // never accepted where a session token is expected
    subject: user.id,
    expiresIn: 5 * 60,
  });
}

Three details matter here:

  • A separate audience. The session middleware verifies a different audience, so the ticket cannot be used as a session token, even though both are signed with the same secret.
  • A short expiry. Five minutes is plenty to open an authenticator app.
  • A token version. tv must still match the user's tokenVersion at the second step, so signing out everywhere or changing the password also kills tickets that are already issued.

Enrolment: a pending secret until it is proven

Setup generates a 160-bit secret and shows it as a QR code built from an otpauth:// URI. The secret is stored as pending; MFA is only switched on once the admin types a valid code from the app, which proves the scan worked. Using otplib:

import { generateSecret, generateURI, verify } from 'otplib';
import QRCode from 'qrcode';

const secret = generateSecret({ length: 20 }); // 20 bytes = 160 bits

const otpauthUrl = generateURI({
  issuer: 'Portfolio Admin',
  label: user.email,
  secret,
  algorithm: 'sha1',
  digits: 6,
  period: 30,
});
const qrDataUrl = await QRCode.toDataURL(otpauthUrl, { errorCorrectionLevel: 'M', width: 240 });

SHA-1, six digits and a 30-second period are what every mainstream authenticator app expects. Changing them buys nothing and breaks compatibility.

Verifying a code exactly once

A TOTP code stays valid for its whole time window, and I allow one step of clock drift either side. Without extra care, someone who watches a code being typed could reuse it within that window. The fix is to remember the last time step that was accepted and refuse anything at or before it:

const result = await verify({
  secret,
  token: code,
  algorithm: 'sha1',
  digits: 6,
  period: 30,
  epochTolerance: 30, // one step of drift either side
  afterTimeStep: user.mfaLastUsedStep, // reject steps that were already used
});
if (!result.valid || !('timeStep' in result)) throw invalidCode();
const step = result.timeStep;

Checking in memory is not enough: two requests with the same code can arrive at the same moment. So the step is claimed with a single conditional update, and only the request that actually changes the document wins:

const claimed = await AdminUser.updateOne(
  { _id: user._id, mfaLastUsedStep: mongoose.trusted({ $not: { $gte: step } }) },
  { $set: { mfaLastUsedStep: step } },
);
if (claimed.modifiedCount !== 1) throw invalidCode();

$not: { $gte: step } also matches a document where the field does not exist yet, which covers the first sign-in. mongoose.trusted() is there because this API runs Mongoose with sanitizeFilter on, which would otherwise neutralise any operator in a filter; operators built by the server are marked trusted on purpose.

Encrypting the secret at rest

A TOTP secret is effectively a password. If the database leaks, plain secrets would let an attacker generate valid codes forever. I store them encrypted with AES-256-GCM, and bind each ciphertext to its user with associated data:

import { createCipheriv, randomBytes } from 'node:crypto';

export function encryptSecret(secret: string, userId: string, key: Buffer): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  cipher.setAAD(Buffer.from(`adminuser:${userId}:mfa`));
  const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return ['v1', iv, tag, ciphertext].map((part) => (typeof part === 'string' ? part : part.toString('base64url'))).join('.');
}
  • GCM authenticates as well as encrypting, so a modified ciphertext fails to decrypt instead of producing garbage.
  • The associated data means a ciphertext copied from one admin onto another will not decrypt.
  • The v1 prefix leaves room to change the format later without guessing.
  • The key comes from an environment variable that is required in production. I derive separate subkeys from it with HKDF, one for encryption and one for hashing recovery codes, so the same key is never used for two purposes.

Recovery codes

Phones get lost. When MFA is enabled the admin receives ten one-time recovery codes, shown once. They are stored as HMAC-SHA256 hashes under the server-side subkey, so a leaked database alone is not enough to brute-force them. Using one removes it atomically, with the same "only one request wins" approach:

const spent = await AdminUser.updateOne(
  { _id: user._id, mfaRecoveryCodes: matchedHash },
  { $pull: { mfaRecoveryCodes: matchedHash } },
);
if (spent.modifiedCount !== 1) throw invalidCode();

The codes avoid look-alike characters (no 0/o or 1/l), and input is normalised, so case, spaces and the hyphen do not matter when someone types one back. The comparison against stored hashes uses timingSafeEqual.

Rate limiting and the rest

  • A six-digit code has a million possibilities, so the verify endpoint has its own limiter keyed on IP plus the ticket's user, counting failures only.
  • Every MFA field is select: false in the Mongoose schema, so it never appears in an API response by accident.
  • Responses that carry a secret, a ticket or recovery codes are sent with Cache-Control: no-store.
  • Disabling MFA or regenerating recovery codes requires a current code, not just a session.

Worth the effort

The core of TOTP is a library call. The security comes from everything around it: a ticket that cannot become a session, codes that work exactly once even under concurrency, secrets that are useless without the server's key, and recovery that does not undo all of it. Each piece is small, and together they make a stolen password much less useful.