From a naive login form to argon2id, rotating refresh tokens, MFA, RBAC, and horizontal scale — every decision mapped to the attack it stops. With a working Node.js + Postgres library you can clone.
Most "design authentication" answers stop at "hash the password with bcrypt, hand back a JWT, done."
That sentence is where the interesting problems begin, not where they end. What hash, with what parameters, and why? What's in the JWT, and what happens when you need to log someone out before it expires? Where does the token live in the browser, and which attack does that choice invite? How does any of this survive being run on twelve machines behind a load balancer?
I went and built the whole thing from scratch to find out — a production-grade auth library for Node.js + Express + PostgreSQL, raw pg, no ORM, no framework doing the thinking for me. Call it oathkeeper. The point wasn't to ship yet another auth package; it was to understand the why behind every best practice by implementing each one and breaking it first.
This post is that journey, structured the way you'd answer it on a whiteboard: scope the problem, walk the happy path, then attack each step until it's correct, assemble the layers, and scale it. Every defense maps to a named, real attack.
Want to build it alongside the article? The full source, migrations, tests, and docs are here: oathkeeper on GitHub.
Step 0 — Scope the question
The web runs on HTTP, and HTTP is stateless. Every request arrives with no memory of the last one. It's a coffee shop where the barista forgets you the instant you step away from the counter. So on every request, the server has to answer two questions:
Q1 — Who is making this request? → Authentication (AuthN)
Q2 — Are they allowed to do this? → Authorization (AuthZ)
The airport analogy that makes this stick: at check-in, they inspect your passport to confirm you are who you claim — that's authentication. At the boarding gate, they check your boarding pass to confirm you're allowed on this specific flight — that's authorization. You can be authenticated (we know who you are) and still not authorized (you can't board this flight). And you can never check authorization before authentication.
Mental model: AuthN proves identity. AuthZ enforces policy. AuthN happens roughly once per session; AuthZ happens on every protected action.
A senior answer scopes both axes before drawing anything:
| Functional | Non-functional |
|---|---|
| Signup, login, logout | Security (assume an active attacker, not a polite one) |
| Sessions that survive page reloads | Low latency on the hot path (token checks) |
| MFA / second factor | Horizontal scale (N stateless app servers) |
| Password reset & email verification | Auditability (who did what, when, from where) |
| Role- and resource-level permissions | Operability (bad config fails loud, at boot) |
Keep that table on the board. Every decision below is in service of one of those cells.
Step 1 — Walk the happy path first
Before attacking anything, get the skeleton on the board: what does a normal user's lifetime look like?
Six interactions: signup → login → access → expiry → refresh → logout. Everything that follows is a deep dive into one of these joints and the ways an attacker tries to pry it open.
Step 2 — Storing passwords (the part everyone gets wrong first)
Build it from broken to correct.
Attempt 1 — plaintext. password = 'hunter2' in a column. One database leak and you've handed an attacker every user's password — and because people reuse passwords, their other accounts too. Catastrophic.
Attempt 2 — encrypt it. Encryption is reversible by design; if you can decrypt, so can whoever steals your key. The deeper insight: you should never need to know a user's password. You only need to verify that the string typed at login matches the one set at signup. That's a one-way check, not a recovery problem.
Attempt 3 — hash it. A hash is one-way: easy forward, infeasible backward. Better — but a fast general-purpose hash (MD5, SHA-256) is the wrong tool. A modern GPU computes billions of SHA-256 hashes per second, so an attacker with your hash dump just brute-forces the space. And identical passwords produce identical hashes, so a precomputed rainbow table cracks common passwords instantly.
Attempt 4 — salt + a slow, memory-hard hash. Add a unique random salt per user (kills rainbow tables — identical passwords now hash differently), and use a function that is deliberately expensive in time and memory so each guess costs the attacker real money.
oathkeeper uses argon2id with OWASP 2023 parameters — 64 MB of memory, ~250 ms per hash. The memory cost is the point: GPUs are fast but memory-starved, so memory-hardness is what makes large-scale cracking economically infeasible. argon2 also embeds a random salt in every hash automatically, so there's nothing extra to manage.
const argon2 = require('argon2');
function createArgon2Hasher(config = {}) {
const options = {
type: argon2.argon2id,
memoryCost: config.memoryCost ?? 65536, // 64 MB — the GPU-resistance lever
timeCost: config.timeCost ?? 3,
parallelism: config.parallelism ?? 4,
};
return {
hash: (plaintext) => argon2.hash(plaintext, options),
verify: (plaintext, hash) => argon2.verify(hash, plaintext), // constant-time
};
}
The users table is intentionally boring — note the soft-delete column, which matters later:
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT UNIQUE NOT NULL, -- case-insensitive, no LOWER() everywhere
password_hash TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
mfa_secret TEXT,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ -- soft delete; the DB stays authoritative
);
Reference:
adapters/hasher/and migration001_users.sql.
Step 3 — Sessions vs. tokens (the fork that decides how you scale)
Now we've authenticated someone — how do we remember them across stateless requests?
Option A — server-side sessions. On login, generate a random session ID, store the session server-side, hand the ID to the client as a cookie. Every request, look the ID up. Simple, and trivially revocable (delete the row). The catch is the lookup: the session lives on the server, so either every request hits a shared session store, or you pin users to one machine with sticky sessions. Both fight horizontal scale.
Option B — JWTs (stateless tokens). Put the claims (sub, email, exp) in the token, sign it, and verify the signature on each request with zero storage lookups. Any server with the secret can validate it. This scales beautifully — and has one nasty property: a stateless token is valid until it expires, so you cannot revoke it. Log a user out, ban an account, rotate a leaked token — the JWT keeps working until exp.
The production answer takes both: a hybrid.
- A short-lived access token — a stateless JWT (HS256, ~15 min). It carries identity and authorizes the hot path with no DB lookup. Because it dies in minutes, an inability to revoke it is a small window.
- A long-lived refresh token — a stateful, opaque random string (default 7 days), stored hashed in the DB. It does nothing but mint new access tokens, and because it's stateful, you can revoke it the instant you need to. Short-lived where you want speed, stateful where you need control. That split is the spine of the whole system.
Step 4 — The refresh-token problem (the part worth the whole article)
A refresh token is a long-lived credential, so it's the prize an attacker actually wants. If they steal one, they can mint fresh access tokens for a week. Three defenses, layered.
Defense 1 — hash at rest. Never store the raw refresh token. Store sha256(rawToken). A database dump then contains hashes that are useless for authenticating — same logic as passwords, except refresh tokens are already high-entropy random, so a plain fast SHA-256 is enough (no argon2 needed).
Defense 2 — rotate on every use. Each call to /refresh invalidates the presented token and issues a brand-new one. A refresh token is single-use. This alone shrinks the value of a stolen token to a single window.
Defense 3 — reuse detection with family revocation. This is the clever part. Every token descended from one login shares a family_id. When a token is rotated, it's marked used. If a token that has already been rotated shows up again, exactly one thing has happened: there are two copies of it in the wild — the legitimate client's and a thief's. You can't tell which one you're looking at, so you revoke the entire family and force a fresh login. The attacker's stolen copy self-destructs the moment either party uses it.
The rotation runs inside a single Postgres transaction. If the process crashes between "revoke old" and "issue new," the whole thing rolls back — the old token stays valid and the user isn't locked out.
async function rotateRefreshToken(rawToken, ctx) {
return storage.withTransaction(async (tx) => {
const existing = await tx.getRefreshToken(sha256(rawToken));
if (!existing) throw new InvalidRefreshTokenError();
// Reuse detection: an already-rotated/revoked token is being presented again.
if (existing.rotated_at || existing.revoked_at) {
await tx.revokeRefreshTokenFamily(existing.family_id);
await tx.logEvent('token.reuse_detected', { familyId: existing.family_id });
throw new RefreshTokenReuseError();
}
const next = generateToken(); // new opaque secret
await tx.insertRefreshToken({
userId: existing.user_id,
familyId: existing.family_id, // stays in the same family
parentId: existing.id, // rotation chain for forensics
tokenHash: sha256(next),
expiresAt: addTtl(refreshTokenTtl),
});
await tx.rotateRefreshToken(existing.id); // atomic CAS: mark this one rotated
const user = await tx.getUserById(existing.user_id);
await tx.logEvent('token.refresh', { userId: user.id });
return { user, refreshToken: next };
});
}
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
family_id UUID NOT NULL, -- all tokens from one login
token_hash TEXT NOT NULL, -- sha256(rawToken), never the raw value
parent_id UUID REFERENCES refresh_tokens(id), -- rotation chain
expires_at TIMESTAMPTZ NOT NULL,
rotated_at TIMESTAMPTZ, -- set when used to mint a successor
revoked_at TIMESTAMPTZ, -- set on logout / family revocation
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON refresh_tokens (token_hash);
CREATE INDEX ON refresh_tokens (family_id);
One honest caveat. A client on a flaky connection can receive a new token but fail to persist it, then retry with the old (now-rotated) one — which fires reuse detection and logs them out. Treat
REFRESH_REUSE_DETECTEDas "ask the user to log in again," not as a confirmed breach. Naming this trade-off out loud is exactly the kind of nuance that separates a senior answer from a memorized one.Reference:
services/tokenService.jsand migration002_refresh_tokens.sql.
Step 5 — Where the token lives, and the CSRF tax
You've got tokens. Where does the client keep them? Two modes, two threat profiles.
Header mode (mobile / API / CLI). Both tokens come back in the JSON body; the client holds the access token in memory and sends Authorization: Bearer <token>. No cookies, so no CSRF risk — a forged cross-site request can't attach a header it can't read.
Cookie mode (browsers). The refresh token is set as an HttpOnly, Secure, SameSite=Lax cookie scoped to the refresh path, so JavaScript can't read it (that defeats token theft via XSS) and the browser only sends it to the one endpoint that needs it. The access token still lives in memory — never localStorage, which any injected script can read.
Cookies buy XSS resistance but introduce CSRF: the browser attaches that cookie automatically, even on a request forged by a malicious page. The fix is the double-submit cookie pattern.
The attacker's browser sends the cookie but can't read it (Same-Origin Policy), so it can't echo the matching header. The server compares header to cookie in constant time. Bearer requests skip the check entirely — they're not cookie-driven, not vulnerable.
Step 6 — Stop forged tokens (JWT hardening)
A JWT is only as trustworthy as your verification. Three classic failures:
-
The
alg: noneattack (CVE-2015-9235). Some libraries honor analgheader ofnoneand accept unsigned tokens — an attacker just edits the claims. The fix is to pin the algorithm on verify and never trust the token's own header. -
Weak secret. A short HS256 secret is brute-forceable offline against any captured token. oathkeeper refuses to boot unless
jwtSecretis ≥ 32 bytes of entropy. -
Token confusion. A token minted for one purpose shouldn't be reusable for another. The MFA challenge JWT carries
purpose: 'mfa_challenge', and the MFA endpoint validates that claim — so a normal access token can't be used to walk past the second factor.
const jwt = require('jsonwebtoken');
function createJwtSigner({ secret, issuer }) {
if (Buffer.byteLength(secret) < 32) {
throw new Error('jwtSecret must be at least 32 bytes'); // fail loud, at boot
}
return {
sign: (payload, opts) =>
jwt.sign(payload, secret, { algorithm: 'HS256', issuer, ...opts }),
verify: (token) =>
jwt.verify(token, secret, { algorithms: ['HS256'], issuer }), // pinned — alg:none rejected
};
}
Step 7 — Don't leak who exists (account enumeration)
Your login endpoint can betray your users without ever being "hacked." If "no such account" and "wrong password" return different messages — or take different amounts of time — an attacker can map out exactly which emails are registered, which fuels targeted phishing and credential stuffing.
Two defenses, working together:
-
Identical response. Both cases return the same
INVALID_CREDENTIALS. The client can't distinguish them. - Identical timing. When the account doesn't exist there's no hash to verify, so a naive implementation returns faster — a measurable tell. Fix: verify the supplied password against a precomputed dummy hash even when no user is found, so the ~250 ms argon2 cost is paid either way.
async function login(email, password, ctx) {
const credential = await storage.getCredentialByEmail(email);
// Spend the same ~250ms whether or not the account exists.
const hash = credential?.password_hash ?? (await DUMMY_HASH);
const ok = await hasher.verify(password, hash);
if (!credential || !ok) {
await storage.logEvent('login.failure', { email });
throw new InvalidCredentialsError(); // identical message for both cases
}
// ...issue tokens, or return an MFA challenge
}
The same principle applies to /password/reset/request and /email/verify/request: they always return a generic 200, whether or not the email exists. The reset email is sent only if the account is real — but the response never reveals that.
Step 8 — A second factor (MFA via TOTP)
Passwords leak. A second factor from a different category (something you have, not another thing you know) means a leaked password alone isn't enough. oathkeeper implements TOTP (RFC 6238) — the 6-digit codes from Google Authenticator, Authy, 1Password.
How TOTP works: server and client share a secret at enrollment. The code is HOTP(secret, floor(currentUnixTime / 30)) — both sides compute it independently from the shared secret and the clock, so nothing is transmitted. Enrollment is deliberately two-step so a half-finished setup can't lock the user out of their own account.
Two subtleties most tutorials skip:
-
Replay protection. A TOTP code is valid for a 30-second window, so an attacker who sniffs one could replay it within that window. oathkeeper tracks used codes in a replay store keyed by
{secretFingerprint}:{counter}— the fingerprint is a 16-char SHA-256 of the secret, so raw TOTP secrets never leak into the key namespace. A code accepted once is rejected for the rest of its window. -
Recovery codes. Lose your phone and you're locked out — so enrollment also returns 10 single-use backup codes. They're treated like passwords: argon2id-hashed at rest, shown in plaintext exactly once.
> Reference:
utils/totp.js(the RFC 6238 implementation is validated against the spec's published Appendix D test vectors) andservices/mfaService.js.
Step 9 — Authorization, at last (RBAC + ABAC)
We've spent eight steps on who you are. Now: what are you allowed to do?
RBAC (role-based) is the floor. Users get roles; roles bundle permissions. "Is this user allowed to doc:edit?" becomes "does any of their roles include the doc:edit permission?" Clean and cacheable.
But RBAC can't express "can edit **this* document."* Roles don't know about individual resources. That's ABAC (attribute-based) — rules that look at the resource itself. oathkeeper adds an optional policy registry on top of RBAC, with one inviolable rule: policies can only restrict, never grant. RBAC is always checked first and is always the floor, so an attacker can't manufacture access by fiddling with a resource's attributes.
async function can(user, action, resource) {
// 1. RBAC is the floor — no permission, no access. Full stop.
const permissions = await getUserPermissions(user.id); // Set<string>
if (!permissions.has(action)) return false;
// 2. Optional ABAC policy can only NARROW, never grant.
const policy = policies[action];
if (policy) return policy(user, resource) === true;
return true;
}
In routes this surfaces as guards: app.put('/documents/:id', authenticate, requirePermission('doc:edit'), handler). The library provides the mechanism (can(), the guards, the schema); your application provides the rules ('doc:edit': (user, doc) => doc.ownerId === user.id), because only your domain knows them.
Reference:
services/rbacService.jsand migration006_rbac.sql.
Step 10 — Reset, verify, and the audit trail
One-time tokens (password reset, email verification) are credentials too, and they fail in predictable ways: predictable values, reusability, no expiry, or not killing existing sessions. oathkeeper's reset tokens are 32 bytes of crypto.randomBytes (unpredictable), stored as SHA-256 (a DB dump doesn't hand over working links), time-bound (30-minute default), and single-use under concurrency via an atomic SQL consume:
UPDATE password_reset_tokens
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL -- the atomicity: exactly one concurrent caller wins
AND expires_at > now()
RETURNING user_id;
-- Zero rows back => already used, expired, or never existed.
Two requests racing to use the same token? The database arbitrates — one gets the row, the other gets nothing. And a successful reset revokes every refresh token for that user, so an attacker who'd hijacked a session can't ride it through the password change.
Audit log. Every meaningful event — login.success, login.failure, token.refresh, token.reuse_detected, mfa.enabled, password.reset.completed — is written to an auth_events table with the user, IP, user agent, timestamp, and a JSONB metadata column for event-specific context. It's the difference between "we think we were fine" and "here's exactly what happened, indexed and queryable."
CREATE TABLE auth_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id UUID REFERENCES users(id),
type TEXT NOT NULL, -- 'login.success', 'token.reuse_detected', ...
ip INET,
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON auth_events (user_id);
CREATE INDEX ON auth_events (type);
Step 11 — Assemble the layers
Step back from the features and look at the shape. oathkeeper is three layers, each depending only on the one below it.
Why this matters:
- The HTTP layer contains no security logic. Routes translate HTTP to a service call and back — read the body, validate required fields, call one method, shape the response. That's it. Security decisions have exactly one home.
- The service layer holds every invariant. It receives its dependencies through factory parameters and never reaches for global state, which makes each service independently testable and replaceable.
-
The adapter layer is swappable. Each adapter satisfies a small interface contract, so you can swap Postgres for another store, argon2 for scrypt, console mail for SES — without touching auth logic. This seam is precisely what makes the next step (scaling) a drop-in rather than a rewrite.
The full schema is seven migrations:
users,refresh_tokens,email_verification_tokens,password_reset_tokens,mfa_recovery_codes, the four RBAC tables, andauth_events. And a small but underrated touch: boot-time config validation. A missing or weakjwtSecret, arefreshTokenTtlshorter than the access TTL — these crash the process at startup with an actionable message, not during some user's first login at 2 a.m.
Reference:
docs/architecture.mdwalks every data flow.
Step 12 — Now scale it
This is where the interview separates the people who've drawn auth from the people who've run it.
The good news: stateless access tokens scale for free. Any app server with the secret can verify a JWT — no shared session store, no sticky sessions. Put N identical Node processes behind a load balancer and access-token verification just works.
The trap: one piece of state is hiding in process memory. oathkeeper's default rate limiter and TOTP replay store keep their counters in an in-process Map. On a single process, correct. The moment you run more than one process — Node cluster, PM2 cluster mode, multiple Kubernetes pods — each process has its own independent counters, and that's a real security regression:
A user rate-limited on Pod A simply retries and lands on Pod B with a fresh counter. A TOTP code spent on Pod A can be replayed on Pod B inside the same window.
I know the fix, and I'm actively working toward it. The reason Redis isn't in oathkeeper yet isn't that I skipped it — it's that I'm currently doing a deep dive into Redis internals from scratch before I touch it: how it handles persistence, how its data structures actually work under the hood, how it manages expiry, and what the replication model looks like. Same first-principles approach I took with auth. Once I've built that mental model, the integration is a drop-in: both interfaces are tiny — { isRateLimited(key, limit, windowMs), reset(key) } and { has(key), set(key, ttl) } — specifically designed to be swapped without touching a single line of auth logic above them. That seam was intentional from day one.
The rest of the scaling story:
-
Postgres read replicas. The hot read is
authenticate's per-request user lookup. Route those reads to replicas; keep token rotation and writes on the primary (rotation is a transaction with a CAS — it must hit the primary). -
The cost of being correct.
authenticatedoes a live DB lookup on every request — that's deliberate, so a soft-deleted, suspended, or locked user is rejected (USER_NOT_FOUND) the moment it matters, regardless of how long their JWT has left. The DB is authoritative, not the token. At scale you can cache that lookup behind a short TTL, but you're now trading revocation latency for read throughput — name the trade-off, don't hide it.
- Stateless secret distribution. Every app server needs the JWT secret; manage it through your secrets system, never in version control.
Step 13 — What I'd deliberately punt on
A senior answer is as much about what you defer as what you build. For v1, explicitly out of scope:
- WebAuthn / passkeys — a different protocol; a planned future addition behind the same MFA seam.
- Distributed rate limiting beyond Redis, and device/geo/velocity fingerprinting for suspicious-login signals — these need behavioral-analysis infrastructure.
- HaveIBeenPwned check at signup — an external dependency; opt-in for the host app.
- Network-level DDoS protection — that's a WAF/CDN concern, not an auth-library one. Knowing where the library's responsibility ends — and saying so — is the difference between a mechanism you can reason about and a black box you hope is fine.
Recap, and how to build it yourself
Walked end to end, the answer is a chain of concept → attack → defense:
| Step | Defense | Attack it closes |
|---|---|---|
| Passwords | argon2id, 64 MB, ~250 ms | GPU brute-force of leaked hashes |
| Sessions | Stateless access + stateful refresh | Scale vs. revocation tension |
| Refresh | Rotation + reuse detection + family revoke | Stolen-token persistence |
| Storage | SHA-256 at rest | DB-dump token theft |
| Cookies | Double-submit CSRF + HttpOnly | CSRF and XSS token theft |
| JWT | Pinned algorithm + ≥32-byte secret + purpose claim |
alg:none forgery, MFA bypass |
| Login | Identical response + dummy hash | Account enumeration |
| MFA | TOTP + replay store + hashed recovery codes | Password-only compromise, code replay |
| AuthZ | RBAC floor + restrict-only ABAC | Privilege escalation |
| Reset | Atomic single-use token + revoke-all-sessions | Token reuse, surviving sessions |
| Scale | Redis-backed shared state | Multi-process rate-limit / replay bypass |
If you want to internalize this rather than just read it, the best move is to build it yourself and break each step before you fix it — that's the whole reason oathkeeper exists. The repo includes the full source, all seven migrations, a 30-line quickstart, worked examples (minimal and full), the architecture and threat-model docs, and a test suite (including the two non-negotiable anchors: a concurrent one-time-token race, and the RFC 6238 TOTP vectors). Clone it, read it top to bottom, and rebuild it from scratch.
The barista still forgets you the second you walk away. Everything above is just the increasingly careful art of handing them a note that proves who you are — one that a thief can't forge, can't reuse, and can't replay on the machine next door.
If this was useful, the repo is the companion piece — and I post more first-principles backend deep-dives. Questions and corrections welcome in the comments.











Top comments (0)