Docs menu

Features & Build Notes · · 3 min read

Stateless session auth on Workers: HMAC cookie + PBKDF2

Admin-panel auth with no session database and no library: an HMAC-SHA256 cookie token, PBKDF2 password verification via Web Crypto, and constant-time comparison — full source code.

Web CryptoCloudflare WorkersSecurityTypeScript

This site’s admin panel is secured with no session database, no Redis, and no auth library — just the Web Crypto API built into Cloudflare Workers. This doc dissects its two parts: a stateless HMAC-signed session cookie and PBKDF2 password verification.

Why stateless?

Workers run in hundreds of locations at once. Sessions stored in memory or a database mean an extra round-trip on every request. A stateless session puts everything in the cookie itself — the server only has to verify the signature.

Token format: <unix-expiry>.<base64url(HMAC-SHA256)>

1. Signing the session

async function signSession(env: Env, payload: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw", sessionSecret(env), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc("admin-session:" + payload));
  return b64urlEncode(sig);
}

export async function newSessionToken(env: Env): Promise<string> {
  const exp = String(Math.floor(Date.now() / 1000) + SESSION_TTL_S); // 7 days
  return exp + "." + (await signSession(env, exp));
}

Verification = re-sign the claimed expiry, compare constant-time, then check it hasn’t expired:

export async function validSession(env: Env, token: string): Promise<boolean> {
  const [expStr, sig] = splitOnce(token, ".");
  const want = await signSession(env, expStr);
  if (!constantTimeEqual(enc(sig), enc(want))) return false; // timing-attack safe
  const exp = Number.parseInt(expStr, 10);
  return Number.isFinite(exp) && Date.now() / 1000 < exp;
}

The cookie is HttpOnly, Secure, SameSite=Strict — browser JavaScript can never read it.

2. Passwords: PBKDF2, not bcrypt

Workers has no native bcrypt, but Web Crypto provides PBKDF2. The hash is stored as pbkdf2:<iterations>:<salt>:<hash> and verified like this:

const key = await crypto.subtle.importKey("raw", enc(pass), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
  { name: "PBKDF2", hash: "SHA-256", salt, iterations }, // 100,000 — the Workers cap
  key,
  want.length * 8,
);
return constantTimeEqual(new Uint8Array(bits), want);

Important caveat: the Workers runtime caps PBKDF2 at 100,000 iterations — below OWASP’s recommendation (600k for SHA-256). The compensation: a long, random admin password (a 20+ character passphrase) keeps brute force uneconomical.

3. The guard middleware

export const requireAuth: MiddlewareHandler = async (c, next) => {
  if (!(await isAuthed(c))) return c.redirect("/admin/login", 303);
  await next();
};

adminRoutes.use("/admin/*", requireAuth); // login/logout excluded

Lessons

  • crypto.subtle on Workers is enough for solo-admin auth — with zero external dependencies.
  • Always compare hashes with a constant-time XOR, never === — timing differences leak information.
  • Stateless sessions have a trade-off: no per-session revocation before expiry. For a single admin, rotating SESSION_SECRET force-logs-out every session.

Want something like this built for your business?