
# Login with 2FA

> A password check, a one-time password check, replay refused, and both branches costing the same.

## What you store per user

| Column         | Holds                                                                    |
| -------------- | ------------------------------------------------------------------------ |
| `passwordHash` | The Argon2 PHC string.                                                   |
| `otpSecret`    | The base32 secret, once the user has confirmed enrolment.                |
| `lastOtpStep`  | The time step of the last accepted code. `null` until the first success. |

## Enrolment

Never enable the factor before the user has proved they can produce a code from it, or a mis-scanned QR locks them out.

```ts
import { generateOTPSecret, otpauthURI, totpVerify } from "unsecure/otp";

export async function begin2FA(user: User) {
  const secret = generateOTPSecret(); // 20 bytes, base32
  await store.setPendingOtpSecret(user.id, secret);

  return otpauthURI({
    type: "totp",
    secret,
    account: user.email,
    issuer: "My App",
  });
  // "otpauth://totp/My%20App:user%40example.com?secret=…&algorithm=SHA1&digits=6&period=30"
}

export async function confirm2FA(user: User, code: string) {
  const secret = await store.getPendingOtpSecret(user.id);
  const result = await totpVerify(secret, code);
  if (!result.valid) return false;

  await store.enableOtp(user.id, secret, result.step);
  return true;
}
```

Render the URI as a QR code. Show the secret as text too, for a user typing it in by hand.

## The login

```ts
import { argon2Hash, argon2NeedsRehash, argon2Verify } from "unsecure/argon2";
import { totpVerify } from "unsecure/otp";
import { randomJitter } from "unsecure/random";

// A hash of a value nobody holds, so the absent-account branch costs the same
const ABSENT = "$argon2id$v=19$m=19456,t=2,p=1$c29tZS1maXhlZC1zYWx0$…";

export async function login(email: string, password: string, code?: string) {
  const user = await store.findByEmail(email);

  const passwordOk = await argon2Verify(user?.passwordHash ?? ABSENT, password, {
    secret: env.PASSWORD_PEPPER,
  });

  if (!user || !passwordOk) {
    await randomJitter(100, 300);
    return { ok: false as const };
  }

  if (user.otpSecret) {
    if (!code) return { ok: false as const, needsOtp: true };

    const result = await totpVerify(user.otpSecret, code, {
      lastAccepted: user.lastOtpStep ?? undefined,
    });
    if (!result.valid) {
      await randomJitter(100, 300);
      return { ok: false as const };
    }
    await store.setLastOtpStep(user.id, result.step);
  }

  if (argon2NeedsRehash(user.passwordHash)) {
    await store.setPasswordHash(
      user.id,
      await argon2Hash(password, { secret: env.PASSWORD_PEPPER }),
    );
  }

  return { ok: true as const, user };
}
```

## Why each piece is there

**`user?.passwordHash ?? ABSENT`** — skipping the hash when no account matches makes the absent case measurably faster, which turns the login form into an account-existence oracle. Hash something in both branches.

**`lastAccepted`** — a TOTP code stays valid for its whole window, so an attacker who captures one has up to a minute to use it. Passing the last accepted step back means `totpVerify` refuses every candidate at or before it. You store one integer and the refusal happens inside the verify, indistinguishable from a wrong code.

**`randomJitter`** — the verification functions are constant-time internally, but the surrounding code is not. Jitter on the failure path costs nothing and makes what is left much harder to measure.

**`argon2NeedsRehash`** — this is the only moment you hold the plaintext, so it is the only moment you can rewrite the hash at today's parameters. Raising a default then becomes the whole migration.

## Recovery codes

Recovery codes are secrets a user stores, so they get the same treatment as passwords, not the same treatment as OTP secrets.

```ts
import { secureGenerate } from "unsecure/generate";
import { argon2Hash, argon2Verify } from "unsecure/argon2";

export async function issueRecoveryCodes(user: User) {
  const codes = Array.from({ length: 10 }, () =>
    secureGenerate({ length: 12, specials: false, uppercase: false }),
  );
  await store.setRecoveryCodes(user.id, await Promise.all(codes.map((c) => argon2Hash(c))));
  return codes; // shown once
}

export async function useRecoveryCode(user: User, submitted: string) {
  const stored = await store.getRecoveryCodes(user.id);
  for (const [index, phc] of stored.entries()) {
    if (await argon2Verify(phc, submitted)) {
      await store.consumeRecoveryCode(user.id, index); // single use
      return true;
    }
  }
  return false;
}
```

Ten Argon2 hashes at roughly 140 ms each is over a second on one thread. Either run the loop in a worker, or store the codes with lower cost parameters chosen for their much larger entropy.

## What the library will not do for you

**Rate limiting.** Six digits with a one-step window is a small space, and an online guess costs an attacker one request. Limit attempts per account and lock the factor after a handful of failures.

**Clock skew.** The default `window: 1` tolerates 30 seconds either way. Widening it widens the replay window too, which is exactly what `lastAccepted` is closing.

**Secret storage.** `otpSecret` is a bearer credential. Encrypt the column, or derive its encryption key with [`hkdf`](/crypto/key-derivation) from a key that is not in the database.
