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.
Password hashing (PBKDF2)
Section titled “Password hashing (PBKDF2)”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> { /* … */ }Why 100K, not 600K?
Section titled “Why 100K, not 600K?”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.
DB-backed sessions
Section titled “DB-backed sessions”Sessions live in the sessions D1 table, not in signed cookies or JWTs.
Session tokens
Section titled “Session tokens”- 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_hashis 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).
Cookie attributes
Section titled “Cookie attributes”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 dayssecure follows config.isProd — local dev over http:// still works.
Flash messages
Section titled “Flash messages”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.
Session caching (optional, KV-backed)
Section titled “Session caching (optional, KV-backed)”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:
[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.
Route guards
Section titled “Route guards”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').
Guards must call next()
Section titled “Guards must call next()”A guard that returns undefined without calling next() errors with
“Context is not finalized”. Always end the success path with return next().