Two-factor, done properly

At work I built an internal tool that holds people's details, so nobody gets in on a password alone. Every account adds a six-digit code from an authenticator app. I wanted the setting-up part to feel calm and short, not like a compliance form you fill in with your jaw clenched.

Below is that enrolment, rebuilt to run entirely in your browser. It is not a mock-up: scan the code with Google Authenticator, 1Password or whatever you use, type the six digits it shows, and it will check them for real. Nothing leaves this page. The secret comes from crypto.getRandomValues, the codes are checked with Web Crypto, and all of it is forgotten when you reload.

Scan it, type the code, save the recovery codes, then sign in again. Try the same code twice, or a recovery code twice. No app to hand? Show code cheats.

Security before anything else

The first sign-in is a six-step journey: welcome, password, two-factor, then the friendlier things like your profile. The order is not decoration. Which step you are on is always worked out from what the server knows about you, never from the URL, and the two security steps jump the queue.

function journeyStep(me) {
  if (me.mustChangePassword) return 2;
  if (!me.totpEnabled) return 3;
  if (me.onboardingStep <= 0) return 1;
  return Math.min(Math.max(me.onboardingStep, 3) + 1, 6);
}

That Math.max(…, 3) is the bit I'm proudest of, which says something about my life. Once two-factor is on, the ledger counts as at least step three, so if you close the tab while staring at your recovery codes, you are not sent back to set up two-factor again. Doing that would quietly replace your secret and break the app you just paired.

The enrolment

It is two cards. The QR is 176 pixels in the real thing, with a line naming the apps that work and a Can't scan? fold-out that shows the key by hand and an otpauth:// link for when the app is on the same phone. The other card is one input: inputMode="numeric" so phones show the number pad, and autocomplete="one-time-code" so the keyboard can offer the code itself.

Once the code checks out you get eight recovery codes like k3m8-q2vx, a Copy all button, and a checkbox that says I have saved these somewhere safe. Continue stays disabled until you tick it. It's a small amount of friction exactly where friction pays for itself.

What the server checks

It's plain RFC 6238: HMAC-SHA1 over the 30-second step count, six digits, and one step of slack either side for phones whose clocks drift. The comparison uses timingSafeEqual, which the demo copies with a small constant-time compare.

for (const delta of [0, -STEP, STEP]) {
  const at = unixSeconds + delta;
  if (timingSafeEqual(totpCode(secret, at), given))
    return Math.floor(at / STEP);  // the step, not just true
}
return null;

Returning the step rather than a yes is what makes replay protection possible. The database remembers the last step it accepted for each person and refuses anything not newer, so a code someone reads over your shoulder is dead the moment you use it. The code you typed during setup is spent too. A replayed code gets the same "That code is not right" as a wrong one, and a separate audit line. Recovery codes are stored hashed and each works once, and ten failures in fifteen minutes locks the door for a while. The demo keeps all of that in memory; watch the log under it.

What I'd add next

The real form uses a single text field, and I still think that's the safer default: paste works, password managers fill it, and screen readers read one field. But six boxes are nicer to look at, so here is how I'd do them. To be clear, this part is new, it isn't in the tool yet. Typing moves you forward, Backspace on an empty box moves you back, arrow keys walk, and pasting anywhere fills all six. If you enrolled above, it checks against your secret without spending the code.

Six-digit code
Type or paste six digits.
A segmented code input. Still one value underneath, still one-time-code on the first box.

The door

The sign-in screen sits on a dimmed copy of the tool's main grid, 24 columns by 16 rows. Nobody is signed in yet, so nothing can be queried; instead 72 tiles are picked by a Park–Miller generator seeded with 20261101, so they land in the same places on every render. Lit tiles breathe on a 9-second cycle, offset by -3 and -6 seconds so they never pulse together, and every fifth one runs at 13 seconds to break the rhythm. A radial mask hollows out the middle so the card has room to sit.

The sign-in background. All CSS: one keyframe, three nth-child rules.
Built
November 2026
Stack
Next.js, React, Postgres, node:crypto
This page
Vanilla JS, Web Crypto, qrcode-generator
Lives at
An internal tool at work
Source
Private

Built with Claude Code. The names and data here are invented; the logic is ported from the real thing.