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

  • /login with the Turnstile widget and the demo credentials visible → 11-login-page.png
  • Successful sign-in: signed-in header state → 11-signed-in.png
  • password123 attempt → 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/login using cf.waf.credential_check → 11-waf-rule.png
  • D1 → sessions table 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.