---
title: 'Multi-Factor Authentication'
source: 'https://academia.sh/en/courses/authentication-and-authorization/multi-factor-authentication'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:31+00:00'
license: 'CC BY-SA 4.0'
---

# Multi-Factor Authentication

The independence of factor classes, deriving the time-based code and the cost of the clock skew window, preventing the same code from being used a second time, and storing recovery codes as single-use.

The previous lesson addressed the machine caller's single proof: whoever holds the
key is the caller. For human users, relying on a single proof is not enough. Once a
password is compromised — it may have been reused, typed into a phishing page, or
come from another leak — the whole account opens up.

**Multi-factor authentication** ties login to more than one independent proof. The
source of the gain is not the count but **independence**: if two proofs can be
compromised through the same path, the second proof adds nothing.

## Factor Classes

Proofs fall into three classes: something known (a password, a recovery code),
something possessed (an app on the phone, a hardware key), and something the person
themselves is (biometric). Multi-factor authentication requires at least two proofs
**from different classes**.

The account below issues the time-based code, verifies it, and compares factor
classes.

```bash
cat > mfa.mjs <<'EOF'
// mfa.mjs — time-based one-time code, clock skew, and recovery codes
import { createHmac, createHash, timingSafeEqual } from "node:crypto";

// 1. Time-based code (TOTP): derived from the shared secret and the time slice.
const STEP = 30;                       // seconds
const DIGITS = 6;
function totp(secret, time, step = STEP) {
  const counter = Math.floor(time / step);
  const buffer = Buffer.alloc(8);
  buffer.writeBigUInt64BE(BigInt(counter));
  const digest = createHmac("sha1", secret).update(buffer).digest();
  const offset = digest[digest.length - 1] & 0x0f;
  const value = digest.readUInt32BE(offset) & 0x7fffffff;
  return String(value % 10 ** DIGITS).padStart(DIGITS, "0");
}

const SECRET = Buffer.from("shared-example-secret");
const TIME = 1_770_000_000;
console.log("time            slice       code");
for (const diff of [0, 29, 30, 60]) {
  const t = TIME + diff;
  console.log(`  +${String(diff).padEnd(3)} seconds  ${String(Math.floor(t / STEP)).padEnd(11)} ${totp(SECRET, t)}`);
}

// 2. Clock skew window: how many slices back/forward should the server look?
function verify(secret, submitted, time, window) {
  for (let k = -window; k <= window; k++) {
    const expected = totp(secret, time + k * STEP);
    const a = Buffer.from(submitted), b = Buffer.from(expected);
    if (a.length === b.length && timingSafeEqual(a, b)) return { accepted: true, sliceOffset: k };
  }
  return { accepted: false };
}
const userTime = TIME - 45;                 // the user's clock is 45 seconds behind
const code = totp(SECRET, userTime);
console.log("\nwindow   result    slice offset   attempts");
for (const p of [0, 1, 2]) {
  const result = verify(SECRET, code, TIME, p);
  console.log(`${String(p).padStart(6)}  ${(result.accepted ? "accept" : "reject").padEnd(9)} ${String(result.accepted ? result.sliceOffset : "-").padEnd(13)} ${2 * p + 1}`);
}
console.log("as the window grows, the number of accepted codes increases: every extra slice widens the guessing space.");

// 3. The same code being used a second time is prevented.
const USED = new Set();
function singleUse(subject, submitted, time) {
  const result = verify(SECRET, submitted, time, 1);
  if (!result.accepted) return "reject: invalid code";
  const key = `${subject}:${Math.floor((time + result.sliceOffset * STEP) / STEP)}`;
  if (USED.has(key)) return "reject: code already used";
  USED.add(key);
  return "accept";
}
const currentCode = totp(SECRET, TIME);
console.log("\nsingle-use attempts");
console.log("  first submission  -> " + singleUse("U-1001", currentCode, TIME));
console.log("  second submission -> " + singleUse("U-1001", currentCode, TIME + 5));

// 4. Factor types and recovery: no two factors should fall into the same class.
const FACTORS = [
  { name: "password",                class: "knowledge",  offline: true,  transferable: true },
  { name: "time-based code",         class: "possession", offline: true,  transferable: false },
  { name: "SMS code",                class: "possession", offline: false, transferable: false },
  { name: "hardware security key",   class: "possession", offline: true,  transferable: false },
  { name: "recovery code",           class: "knowledge",  offline: true,  transferable: true },
];
console.log("\nfactor                      class      offline     transferable");
for (const f of FACTORS)
  console.log(`  ${f.name.padEnd(26)} ${f.class.padEnd(10)} ${(f.offline ? "yes" : "no").padEnd(11)} ${f.transferable ? "yes" : "no"}`);
const sameClass = FACTORS.filter((f) => f.class === "knowledge").length;
console.log(`factors in the knowledge class: ${sameClass} — if the second factor is chosen from the first one's class, there is no gain.`);

// 5. Recovery codes: stored single-use and as a hash.
const recovery = ["3f8a-91cd", "77b2-4e0a", "c15d-8823"];
const store = new Map(recovery.map((c) => [createHash("sha256").update(c).digest("hex"), "unused"]));
function tryRecovery(code) {
  const h = createHash("sha256").update(code).digest("hex");
  if (!store.has(h)) return "reject: invalid code";
  if (store.get(h) === "used") return "reject: code was already used";
  store.set(h, "used");
  return "accept";
}
console.log("\nrecovery code attempts");
for (const [name, code] of [["valid code", recovery[0]], ["same code again", recovery[0]], ["not in the list", "0000-0000"]])
  console.log(`  ${name.padEnd(18)} -> ${tryRecovery(code)}`);
console.log(`remaining usable codes: ${[...store.values()].filter((d) => d === "unused").length} / ${recovery.length}`);
EOF
node mfa.mjs
```

```text
time            slice       code
  +0   seconds  59000000    634551
  +29  seconds  59000000    634551
  +30  seconds  59000001    787106
  +60  seconds  59000002    324776

window   result    slice offset   attempts
     0  reject    -             1
     1  reject    -             3
     2  accept    -2            5
as the window grows, the number of accepted codes increases: every extra slice widens the guessing space.

single-use attempts
  first submission  -> accept
  second submission -> reject: code already used

factor                      class      offline     transferable
  password                   knowledge  yes         yes
  time-based code            possession yes         no
  SMS code                   possession no          no
  hardware security key      possession yes         no
  recovery code              knowledge  yes         yes
factors in the knowledge class: 2 — if the second factor is chosen from the first one's class, there is no gain.

recovery code attempts
  valid code         -> accept
  same code again    -> reject: code was already used
  not in the list    -> reject: invalid code
remaining usable codes: 2 / 3
```

## How a Time-Based Code Is Verified

The output's first table shows that the code depends on the time slice: every moment
within the same slice gives the same code, and the code changes when the slice
changes. What produces the code is the shared secret and the slice number; nothing
travels over the network between server and client.

The second table measures clock skew. When the user's clock is forty-five seconds
behind, verification fails with a zero window; it is accepted within a two-slice
window, and the number of slices of difference is reported.

The window's cost shows up in the table: every extra slice increases the number of
accepted codes and widens the guessing space. A one-slice window (three codes total)
is a common balance; keeping the server's clock accurate is preferred over a wider
window.

## The Same Code Cannot Be Used Twice

The third section shows the reuse defense. The code is valid for the length of its
validity slice — about half a minute; accepting the same code a second time within
that span would let someone intercepting it replay the code.

The solution is to record the accepted code's slice on a per-user basis. The record's
lifetime matches the window; old records are cleared out. This is the same pattern as
the reuse list in the Enterprise Federation lesson — the same solution in a different
context.

Attempt limiting is a separate layer: a six-digit code carries a million
possibilities, and can be guessed within the window if unlimited attempts are
allowed. Consecutive failed attempts are counted at the account level, and once the
threshold is exceeded, the second factor is temporarily locked.

## No Gain If the Class Is the Same

The fourth table compares factors by three criteria: class, offline capability, and
transferability. The last column is the most distinguishing one. A password and a
recovery code are **transferable**: the user can unknowingly tell them to someone
else, type them into a page. A time-based code also looks transferable, but its
thirty-second lifetime makes transferring it hard; a hardware key, on the other hand,
cannot be transferred, because the signing operation happens inside the device.

The table's last line states a rule with a number: two of the factors on the list are
in the knowledge class. If the second factor is chosen from the first one's class —
putting a "secret question" alongside the password — the attack surface does not
change, because both are compromised through the same path.

The code sent by SMS occupies a separate position: it is in the possession class, but
it does not work offline and its delivery depends on a third party. It is better than
having no second factor at all; where available, an app-based code or a hardware key
is preferred.

## Recovery Codes Are the Account's Spare Key

The last section runs the recovery codes. When the second factor is lost — the phone
is replaced, the device breaks — the user needs a way back into the account. Recovery
codes open this path, and precisely for that reason are **the account's weakest
link**.

Three rules balance this. The codes are single-use: a used code is not accepted
again, and the number of remaining codes is shown to the user. The codes are stored
as a hash; they cannot be used directly in a database leak. And a notification goes
to the user when a code is used — the recovery flow is the most likely path to
account takeover, and it must not operate silently.

Considered together with the reset flow built in the Account Recovery lesson, the
criterion is this: the paths back into the account must not be weaker than the login
path. If resetting the password can bypass the second factor, there is no second
factor.

## Summary

- Multi-factor authentication's gain comes not from the number of proofs but from the
  proofs being from different classes and independent; in the model, two factors
  fall into the knowledge class.
- The time-based code is derived from the shared secret and the time slice; the
  clock skew window increases the number of accepted codes, so it is kept narrow.
- The accepted code's slice is recorded; the same code is not accepted a second time,
  and the number of attempts is limited separately.
- An SMS code is in the possession class but does not work offline and its delivery
  depends on a third party; an app-based code or a hardware key is preferred.
- Recovery codes are single-use, stored as a hash, and produce a notification when
  used; the path back into the account cannot be weaker than the login path.

## Next Step

Throughout this topic, the server assumed it could verify something the user knew or
possessed. How the password itself is stored, which account that verification is
checked against, and how well the storage method holds up in a leak have not yet been
asked. The next topic goes underneath this assumption and ties password storage to
measurable parameters.
