TOTP Two-Factor Authentication

Six digits from an authenticator app: how they are generated, verified, and where the sharp edges are.

Syntaxotpauth://totp/Issuer:[email protected]?secret=BASE32SECRET&issuer=Issuer&digits=6&period=30

TOTP (Time-based One-Time Password, RFC 6238) is the six-digit code in Google Authenticator, 1Password or Authy. Server and app share a secret; both derive the same code from that secret and the current time.

How it works

  1. At enrolment the server generates a random secret and shows it as a QR code (an otpauth:// URI).
  2. The app stores the secret.
  3. Both sides compute HMAC-SHA1(secret, floor(unixtime / 30)) and truncate it to six digits.
  4. Same secret + same 30-second window = same code. No network between them, which is why it works on a plane.

The details that decide whether it works

  • Accept a small window. Check the current step and one either side (±30s). Wider than that and you materially extend each code's life.
  • Refuse reuse. Store the last accepted step per user; a code that has been used must not work again, or an attacker who observes one has 30 seconds to replay it.
  • Rate-limit hard. Six digits is a million options; unlimited guesses is a few hours of brute force.
  • Confirm before enabling. Require one correct code at enrolment, otherwise you lock out users whose clock or scan failed.
  • Issue recovery codes. Ten single-use codes, hashed at rest, shown once. Losing a phone must not mean losing the account.

Where it belongs

TOTP is a strong second factor and never a first one. And be clear about its limit: it is phishable. A convincing fake login page collects the password and the code, and replays both within 30 seconds. Only origin-bound credentials — WebAuthn — close that.

Do not put it on the API

MFA belongs in the login step, not on every API call. Verify the factor, then mark the session or token as MFA-satisfied and let ordinary authentication carry the rest.

Example

Example · javascript
import { authenticator } from 'otplib';

// Enrolment — generate, show as QR, but do NOT enable yet
const secret = authenticator.generateSecret();      // base32
const uri = authenticator.keyuri('[email protected]', 'SoundsCode', secret);
// otpauth://totp/SoundsCode:[email protected]?secret=...&issuer=SoundsCode

// Verification — allow one step either side for clock drift
authenticator.options = { window: 1 };
const valid = authenticator.verify({ token: '492817', secret });

When to use it

  • An admin console requires TOTP on login, and the session is marked mfa: true so ordinary API calls need no further prompting.
  • A user replaces a lost phone using one of ten recovery codes, which is consumed and cannot be reused.
  • A brute-force attempt against a six-digit code is stopped by a five-attempts-per-fifteen-minutes limit long before the space is exhausted.

More examples

Enrolment, confirmation and recovery codes

totpLastStep is the replay defence. Without it, a code shoulder-surfed or captured by a proxy stays usable for the remainder of its 30-second window.

Example · javascript
import { authenticator } from 'otplib';
import { randomBytes, createHash } from 'crypto';
import argon2 from 'argon2';

authenticator.options = { window: 1, step: 30, digits: 6 };

// 1. Start enrolment — pending until proven
app.post('/mfa/totp/start', session, async (req, res) => {
  const secret = authenticator.generateSecret();
  await db.users.update(req.user.id, {
    totpSecretPending: encrypt(secret),      // encrypted at rest
    totpEnabled: false,
  });
  res.json({
    secret,                                   // for manual entry
    otpauthUri: authenticator.keyuri(req.user.email, 'SoundsCode', secret),
  });
});

// 2. Confirm — one working code before we turn it on
app.post('/mfa/totp/confirm', session, mfaLimiter, async (req, res) => {
  const user = await db.users.findById(req.user.id);
  const secret = decrypt(user.totpSecretPending);
  if (!secret || !authenticator.verify({ token: req.body.code, secret })) {
    return res.status(400).json({ error: 'invalid_code' });
  }

  // Recovery codes: shown ONCE, stored only as hashes
  const recovery = Array.from({ length: 10 },
    () => randomBytes(5).toString('hex').match(/.{1,5}/g).join('-'));

  await db.users.update(user.id, {
    totpSecret: user.totpSecretPending,
    totpSecretPending: null,
    totpEnabled: true,
    totpLastStep: null,
  });
  await db.recoveryCodes.replaceAll(user.id,
    await Promise.all(recovery.map((c) => argon2.hash(c))));

  res.json({ recoveryCodes: recovery });     // the only time they are visible
});

// 3. Verify at login — window, replay protection, rate limit
app.post('/mfa/totp/verify', partialSession, mfaLimiter, async (req, res) => {
  const user = await db.users.findById(req.partialUser.id);
  const secret = decrypt(user.totpSecret);

  const step = Math.floor(Date.now() / 1000 / 30);
  if (user.totpLastStep && step <= user.totpLastStep) {
    return res.status(400).json({ error: 'code_already_used' });
  }
  if (!authenticator.verify({ token: req.body.code, secret })) {
    return res.status(400).json({ error: 'invalid_code' });
  }

  await db.users.update(user.id, { totpLastStep: step });
  await destroySession(req.partialSid);              // rotate on elevation
  res.cookie('sid', await createSession(user.id, req, { mfa: true }), COOKIE_OPTIONS);
  res.status(204).end();
});

The two-step login state machine

Returning 401 mfa_required rather than 403 matters: the client should route the user to the code prompt, not show a permissions error.

Example · javascript
// Password alone must NOT produce a full session when MFA is enabled.
app.post('/login', loginLimiter, async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  if (user.totpEnabled) {
    // A PARTIAL session: it can reach /mfa/* and nothing else.
    const sid = await createSession(user.id, req, { partial: true, ttl: 300 });
    res.cookie('sid', sid, { ...COOKIE_OPTIONS, maxAge: 300_000 });
    return res.status(200).json({ mfaRequired: true, methods: ['totp', 'recovery'] });
  }

  res.cookie('sid', await createSession(user.id, req), COOKIE_OPTIONS);
  res.json({ user: publicProfile(user) });
});

// Every ordinary route rejects a partial session outright.
export function session(req, res, next) {
  const data = req.sessionData;
  if (!data) return res.status(401).json({ error: 'authentication_required' });
  if (data.partial) {
    return res.status(401).json({ error: 'mfa_required' });   // not 403
  }
  req.user = { id: data.userId, mfa: !!data.mfa };
  next();
}

Discussion

  • Be the first to comment on this lesson.