# Design note — two-factor authentication

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

---

## The job to be done

A forum holds email addresses, private messages and, for staff, the ability to
change everything. A password that has been reused somewhere that got breached
should not be enough on its own.

## Decision 1 — TOTP, and nothing else for now

Time-based codes from an authenticator app: RFC 6238, six digits, thirty-second
step, SHA-1. Not because SHA-1 is a good hash — because that is what every
authenticator app implements, and a standard nobody's app supports is not
security, it is a support ticket.

**No SMS.** SIM swapping makes it weaker than the password it is protecting,
and it costs money per message. **No email codes** — the email account is
usually what an attacker already has if they have the password.

## Decision 2 — the secret is encrypted at rest, and 2FA refuses to work without a key

A TOTP secret is a password equivalent: anyone holding it can generate codes
forever. Storing it in clear means a database dump is a complete bypass of the
whole feature.

So it is encrypted with the application key, AES-256-GCM, which also
authenticates the ciphertext — a secret somebody tampered with fails to decrypt
rather than silently becoming a different secret.

**`APP_KEY` is empty on a fresh install**, which would make that encryption
theatre. So:

- A `key:generate` command exists, and
- **enrolment refuses outright when there is no key**, saying why.

A feature that silently provides less protection than it claims is worse than
one that says it is not ready.

## Decision 3 — recovery codes, hashed, single use

Ten codes, shown once at enrolment. Hashed the same way passwords are, because
they *are* passwords — a set of ten written to the database in clear is ten
more ways in.

Each is consumed on use. Losing a phone is the ordinary case, not the
exceptional one, and a forum with no recovery path means an administrator
restoring somebody by hand in the database.

When they run low the account is told. Nobody counts their remaining codes.

## Decision 4 — a used code cannot be used again

The counter of the last accepted step is stored, and a code from that step or
earlier is refused. Without this, a code shoulder-surfed or read from a proxy
log is valid for the rest of its window — which is most of thirty seconds, and
plenty.

One step of clock drift either way is accepted, because phone clocks drift and
the alternative is people who cannot sign in.

## Decision 5 — the second factor is a second step, and the first step grants nothing

A correct password puts a **pending** marker in the session and nothing else.
No `user_id`, so nothing anywhere treats it as signed in. Only a correct code
promotes it.

The pending state carries its own expiry and is cleared on use, so a half-
finished sign-in left on a shared machine does not stay useful.

Rate limited on the code step separately from the password step: six digits is
a million possibilities, and a million is not many if you can try them fast.

## Decision 6 — enrolment shows the key, not a QR code

An `otpauth://` link and the secret in readable blocks. Tapping the link on a
phone opens the authenticator directly; typing the secret works everywhere.

**No QR code**, deliberately: rendering one means a QR encoder, and an encoder
I cannot test against a real scanner is a picture I would be *hoping* is
correct. A wrong QR at enrolment locks somebody out of their own account. The
link and the key are both verifiable, so they ship and the QR waits for a way
to check it.

## Decision 7 — administrators can be required to have it

A setting: require 2FA of anybody who can reach the admin panel. Those accounts
are the ones worth attacking, and "we should all turn it on" is not a control.

Somebody in scope who has not enrolled is sent to enrolment and cannot reach
the admin panel until they finish — not locked out of the forum, because
locking staff out of the site to make them do a security task is how the
setting gets turned off again.

## What this does not do

- **No WebAuthn or passkeys.** The right long-term answer and a much larger
  feature: registration ceremonies, attestation, multiple credentials per
  account. It deserves its own note.
- **No trusted devices / "don't ask for 30 days".** It is a cookie that skips
  the second factor, which needs its own careful thought about theft and
  revocation.
- **No enforcing 2FA on ordinary members.** A forum that demands it to read a
  thread loses the member.
