Customer sign-in done properly: PBKDF2, D1 sessions — and a WAF rule that knows leaked passwords
A customer login is a stack of small decisions: how passwords are hashed, how sessions are stored and revoked, what an error response leaks. The Lumina demo builds each layer with Web Crypto and D1 — and then adds a Cloudflare edge rule that stops breached passwords before the app ever sees them. This post is the login, the sessions, and the war story where the WAF rule turned out to be smarter than the demo’s own password.
The story
The demo has four seeded accounts (a “roster”) for demoing signed-in flows. Passwords are hashed with PBKDF2-SHA-256 — Web Crypto, right in the Worker — at 25,000 iterations, tuned to stay inside the free plan’s 10 ms CPU budget (measured ~3 ms):
async function derive(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
return new Uint8Array(await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256));
}
Verification compares digests in constant time and parses the stored
pbkdf2$<iterations>$<salt>$<digest> string so iteration counts can migrate later.
Sessions are D1 rows, not JWTs — revocable by design:
export async function createSession(env: Env, userId: string): Promise<string> {
const bytes = crypto.getRandomValues(new Uint8Array(32)); // 256-bit token
const token = base64url(bytes);
await env.DB.batch([
env.DB.prepare('DELETE FROM sessions WHERE user_id = ?'), // rotate: one session per account
env.DB.prepare(
"INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, datetime('now', '+1 day'))"
).bind(crypto.randomUUID(), userId, await sha256Hex(token)), // hash at rest
]);
return token;
}
Three habits worth copying: the cookie carries a random 256-bit token while the DB stores only
its SHA-256 hash (a DB leak can’t be replayed as cookies); the expiry is enforced by the
query (expires_at > datetime('now')), so a stale cookie can’t outlive its row; and
signing in again rotates the old session out — re-login kills stale cookies, and the table
stays bounded.
The login flow itself is layered: Turnstile first (stop credential stuffing before the DB is touched), then per-IP and per-email rate limits, then one identical generic 401 for unknown email, passwordless account and wrong password. On success the anonymous cart is claimed so history follows the account.
The edge layer: blocking breached passwords at the WAF
On /api/auth/login sits a zone WAF rule using Cloudflare’s compromised-credential check
(cf.waf.credential_check): if a submitted password appears in a breach corpus, the request is
edge-blocked with an HTML block page — before the Worker runs, before siteverify, before
D1. The app layer (Turnstile + rate limits) handles what the edge can’t know: credential
stuffing patterns, not corpus membership.
And then the rule earned its keep the hard way. The demo’s original roster password kept
getting blocked at the edge — turns out it was in Cloudflare’s compromised-credential
dataset. Any username plus that password → edge block; a one-character variation → passed.
The WAF rule was right, the demo password was wrong. It was rotated to a corpus-clean value,
verified before shipping (migration 0005), and the attack page now demos the precision: four
leaked password pairs get 🛡️ edge blocks while the valid roster password sails past the edge
and is stopped only by the app layer.
What the demo shows
Sign in at /login as a roster account (credentials are printed on the page — public demo
data) → the header shows the signed-in name; place an order and reload in a second browser
signed in as the same account → history follows. Try password123 → “Blocked at the Cloudflare
edge (WAF)”, with the event in Security → Events.
Evidence: what to capture
-
/loginwith the Turnstile widget and the demo credentials visible →11-login-page.png - Successful sign-in: signed-in header state →
11-signed-in.png -
password123attempt → the edge-block message →11-edge-blocked.png - Security → Events: the credential-check block event →
11-security-events.png - Cloudflare dashboard → WAF custom rule on
/api/auth/loginusingcf.waf.credential_check→11-waf-rule.png - D1 →
sessionstable showing the hash-at-rest row →11-sessions-table.png
Key takeaways
- Web Crypto makes real password hashing (PBKDF2) trivially available inside a Worker — no native add-ons.
- Store token hashes at rest, expire in the query, rotate on login: sessions stay revocable and bounded.
- Layer the defenses where each is cheapest: the edge knows breach corpora; the app knows behavior.
- A rule that blocks your own valid traffic isn’t always wrong — sometimes it’s the most honest audit you’ll get.