16 min read
Implemented in Registration Kit
Full-stack auth starter: registration, login, 2FA, and password reset — React + Express + MongoDB
See Registration KitEvery Node authentication tutorial ends at the same place: a form, a bcrypt compare, a token. That's maybe a fifth of what shipping auth actually requires. The other four fifths — reset, verification, two-factor, lockout — is where the security bugs are, and it's the part you write alone at two in the morning.
This covers the flows that come after the login form, with the mistakes that are easy to make in each.
import argon2 from 'argon2';
export const hashPassword = (plain: string) =>
argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB — OWASP minimum
timeCost: 2,
parallelism: 1,
});
export const verifyPassword = (hash: string, plain: string) =>
argon2.verify(hash, plain);Argon2id is the current recommendation. bcrypt is still perfectly acceptable — it's battle-tested and everywhere — but use a cost of 12 or higher, and know that bcrypt silently truncates at 72 bytes:
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(plain, 12);Two rules that survive whichever you pick. Never roll your own — not SHA-256, not SHA-256 with a salt, not SHA-256 in a loop. Fast hashes are the wrong tool; the entire point is to be slow. And never truncate or normalise the password before hashing beyond a Unicode NFC pass — no trimming, no lowercasing, no stripping characters.
const DUMMY = '$argon2id$v=19$m=19456,t=2,p=1$...'; // hash of a random string, computed once
export async function login(email: string, password: string) {
const user = await users.byEmail(email);
if (!user) {
await argon2.verify(DUMMY, password).catch(() => false); // burn the same time
throw invalid('invalid credentials');
}
if (!(await verifyPassword(user.passwordHash, password))) {
throw invalid('invalid credentials'); // same message
}
return user;
}| Session cookie | JWT | |
|---|---|---|
| Server state | yes | no |
| Instant revocation | yes, delete the row | no, wait for expiry |
| Horizontal scaling | needs shared store | free |
| Mobile / third-party clients | awkward | natural |
| Verification cost | one store lookup | signature check |
| XSS exposure | none, if httpOnly | high, if in localStorage |
The plain truth: if you're building a web app with your own frontend, sessions are simpler and safer. A Redis or Postgres session table gives you instant logout, "sign out everywhere", and a list of active devices — three features that are trivial with sessions and awkward with JWTs. JWTs earn their complexity when you have several services, or mobile clients, or you genuinely cannot keep state.
Whichever you choose, the cookie flags are the same and they're not optional:
res.cookie('sid', sessionId, {
httpOnly: true, // JS cannot read it — this is the XSS defence
secure: true, // HTTPS only
sameSite: 'lax', // 'strict' breaks OAuth returns; 'lax' is the sane default
path: '/',
maxAge: 1000 * 60 * 60 * 24 * 7,
});sameSite: 'lax' is what stops most CSRF on state-changing requests. If you need sameSite: 'none' for a cross-site setup, you need CSRF tokens as well — there's no way around it.
Six digits from an authenticator app. The library is easy; the surrounding flow is where it goes wrong.
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
// 1 — enrolment: generate a secret, but DO NOT enable 2FA yet
export async function beginEnrolment(user: User) {
const secret = authenticator.generateSecret(); // 32 chars, base32
await users.setPendingTotpSecret(user.id, encrypt(secret));
const uri = authenticator.keyuri(user.email, 'Dashforge', secret);
return { qr: await QRCode.toDataURL(uri), secret };
}
// 2 — confirm: only now is 2FA actually on
export async function confirmEnrolment(user: User, code: string) {
const secret = decrypt(await users.pendingTotpSecret(user.id));
if (!authenticator.verify({ token: code, secret })) {
throw invalid('invalid code');
}
await users.enableTotp(user.id);
return generateBackupCodes(user.id);
}The two-step enrolment is the whole point. If you enable 2FA the moment you generate the secret, a user who closes the tab before scanning the QR code is permanently locked out of their own account, and only you can rescue them.
Three more details that only show up in production:
Clock drift. otplib accepts a one-step window by default (±30 s). Widen it to two if you get complaints, no further — every step you add doubles the guessing window.
Replay. A valid code stays valid for its whole 30-second step. Store the last accepted code per user and reject a repeat, otherwise someone who shoulder-surfs a code has half a minute to use it.
Encrypt the secret at rest. It's a password equivalent. A database dump with plaintext TOTP secrets means every second factor in your system is compromised.
Phones get lost. Without backup codes, that's a support ticket and an identity check you probably can't do properly.
async function generateBackupCodes(userId: string) {
const codes = Array.from({ length: 10 }, () =>
crypto.randomBytes(5).toString('hex'), // 10 hex chars
);
await users.replaceBackupCodes(
userId,
await Promise.all(codes.map((c) => argon2.hash(c))), // hashed, like passwords
);
return codes; // shown exactly once, never again
}Hash them. Single-use — delete the row when one is consumed. Show them once and say so plainly on screen.
export async function requestReset(email: string) {
const user = await users.byEmail(email);
// 1 — Always the same response. Never confirm whether the email exists.
if (user) {
const raw = crypto.randomBytes(32).toString('base64url');
const hash = crypto.createHash('sha256').update(raw).digest();
// 2 — Store the HASH. A leaked database must not contain usable reset links.
await resets.create({
userId: user.id,
tokenHash: hash,
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 3 — short
});
await mail.sendReset(user.email, raw);
}
return { ok: true }; // identical for both branches
}
export async function completeReset(rawToken: string, newPassword: string) {
const hash = crypto.createHash('sha256').update(rawToken).digest();
const rec = await resets.byHash(hash);
if (!rec || rec.usedAt || rec.expiresAt < new Date()) {
throw invalid('invalid or expired reset link');
}
await users.setPassword(rec.userId, await hashPassword(newPassword));
await resets.markUsed(rec.id);
// The step everyone forgets:
await sessions.revokeAllForUser(rec.userId);
}That last line is the one. If someone reset the password because an attacker was in the account, and you leave the attacker's session alive, the reset accomplished nothing.
Same for a normal password change: revoke every other session, keep the current one.
Use a separate table and a separate purpose. A reset token that also verifies email — or the reverse — is a privilege-escalation bug waiting for someone to notice: request a "verify" link for an address you don't own, and use it to reset the password.
Different table, different expiry (24 h is fine for verification), different endpoint.
Per-account, not just per-IP — an attacker rotates IPs for pennies and a per-IP limit alone stops nobody.
const LOCK_AFTER = 10;
const LOCK_FOR_MS = 15 * 60 * 1000;
export async function recordFailure(userId: string) {
const n = await counters.increment(`login:${userId}`, LOCK_FOR_MS);
if (n >= LOCK_AFTER) {
await users.lockUntil(userId, new Date(Date.now() + LOCK_FOR_MS));
}
}A temporary lock, not a permanent one — permanent lockout is a denial-of-service against your own users, since anyone who knows an email address can trigger it. Clear the counter on a successful login, and rate-limit the reset and verification endpoints too: they send email, so an unlimited one is a free spam cannon pointed at your domain reputation.
httpOnly, secure, sameSite.Registration Kit ships every flow above — registration, email OTP verification, login, TOTP two-factor with backup codes, forgot and reset password, change password — in Node and in Go, on the same API contract.