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.
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.subtleon 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_SECRETforce-logs-out every session.
Want something like this built for your business?