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.
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
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.
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 from a key that is not in the database.