Skip to content

Sessions & guards

Kilat ships a complete auth layer in src/server/auth.ts — password hashing via Web Crypto, DB-backed sessions with httpOnly cookies, and composable route guards. No external auth library is required.

Passwords are hashed with PBKDF2-HMAC-SHA256 using the Web Crypto API (crypto.subtle), not node:crypto (which is unavailable on Workers).

const PBKDF2_ITERATIONS = 100_000; // Workers caps PBKDF2 at 100K iterations
export async function hashPassword(password: string): Promise<string> { /* … */ }
export async function verifyPassword(password: string, stored: string): Promise<boolean> { /* … */ }

OWASP recommends 600K iterations for PBKDF2-HMAC-SHA256, but Cloudflare Workers caps PBKDF2 at 100K iterations — requesting 600K throws NotSupportedError. 100K is still OWASP-acceptable for PBKDF2-HMAC-SHA256 and runs within the Workers CPU budget. Do not raise this number.

The stored format embeds the salt and iteration count, so verifyPassword parses them back out — future parameter changes stay backward-compatible.

Sessions live in the sessions D1 table, not in signed cookies or JWTs.

  • 256-bit random (crypto.getRandomValues), hex-encoded into the cookie.
  • The raw token never touches the DB — only its SHA-256 hash is stored (token_hash is the primary key). A database leak cannot expose valid session tokens.
  • Token rotation on login/register defends against session fixation.
export async function createSession(userId: number): Promise<SessionInfo> { /* … */ }
export async function resolveSession(token: string | null | undefined): Promise<ResolvedSession | null> { /* … */ }
export async function deleteSessionByToken(token: string): Promise<void> { /* … */ }

resolveSession is the single entry point used by the Inertia middleware. It returns both the public user and one-shot flash data in one call — 2 D1 queries (findSession + findUserById), down from the previous 3 (which read the session row twice).

The session cookie is set with:

Attribute Value
httpOnly true (no JS access)
sameSite Lax (blocks cross-site POSTs)
secure true in production only
path /
maxAge 30 days (SESSION_TTL_MS)
export const SESSION_COOKIE = "session";
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

secure follows config.isProd — local dev over http:// still works.

One-shot flash messages (e.g. “Welcome back!”) are stored as JSON on the session row and consumed on the next render via readFlash / setFlash / clearFlash.

By default, every request resolves the session from D1 via resolveSession — 2 D1 queries per request. This is correct and simple, but D1 is single-threaded per database — throughput caps at ~1,000 QPS for sub-millisecond queries. At thousands of RPS, session lookups become the bottleneck.

Enable KV-backed session caching to eliminate D1 queries on cache hits:

wrangler.toml
[vars]
SESSION_CACHE_ENABLED = "true"
SESSION_CACHE_TTL_SECONDS = "300"
[[kv_namespaces]]
binding = "SESSION_KV"
id = "your-session-kv-namespace-id"

How it works: resolveSession checks KV first (session:{hash} key). On a hit, it returns the cached user + flash with zero D1 queries. On a miss, it falls back to D1, then populates KV with the session data and TTL. Logout (deleteSessionByToken) and flash consumption (clearFlash) invalidate the KV entry. D1 remains the source of truth.

Scenario D1 queries KV ops Notes
Cache disabled (default) 2 0 Every request hits D1
Cache hit 0 1 get ~99% of requests for active users
Cache miss (first request per session) 2 1 get + 1 put Populates cache for subsequent hits

Security trade-off: deleteOtherSessionsByToken (password change) cannot enumerate other sessions’ KV keys — revoked sessions on other devices may remain cache-valid for up to SESSION_CACHE_TTL_SECONDS (default 300s). Reduce the TTL or disable the cache if this revocation window is unacceptable.

The cache is off by default and degrades gracefully: without the SESSION_KV binding (tests, local dev), initSessionCache receives undefined and all cache operations are no-ops — resolveSession falls back to D1-only. See Limits for D1 throughput details.

Guards are Hono middleware in auth.ts. They short-circuit with a redirect Response or call next() to continue the chain.

import { requireAuth, guestOnly, requireRole } from "../auth";
// Require a logged-in user — redirects to /login otherwise.
app.get("/dashboard", requireAuth, (c) => c.var.inertia.render("Dashboard"));
// Only for guests — redirects logged-in users to /dashboard.
app.get("/login", guestOnly, (c) => c.var.inertia.render("Login"));
// Role-gated — non-admins redirect to /dashboard.
app.get("/admin", requireAuth, requireRole("admin"), (c) => /* … */);
Guard Behavior
requireAuth No user → redirect to /login
guestOnly User present → redirect to /dashboard
requireRole('admin') No user → /login; wrong role → /dashboard

requireRole is a factory — pass one or more roles: requireRole('admin'), requireRole('admin', 'editor').

A guard that returns undefined without calling next() errors with “Context is not finalized”. Always end the success path with return next().