Hi, Devlin. Ten years of fullstack, currently in Norway, currently holding a coffee. Sessions are one of those features where the spec is ten lines, the security writeups are a thousand lines, and every framework solves them slightly differently. The new session() middleware in DaloyJS is what happened when we sat down and said: okay, but what would session management look like if you didn't have to remember anything?
Short version: signed cookies with __Host-daloy.sid defaults, multi-secret rotation built in, a regenerate() that does the fixation-safe dance for you, a MemorySessionStore for tests, and a three-method SessionStore interface so Redis, Workers KV, Vercel KV, Postgres — any of them — is twenty lines.
The smallest useful setup
// src/app.ts — the smallest useful session setup
import { App, session, type SessionContext } from "@daloyjs/core";
// Add the session shape to your AppState so handlers get full typing.
declare module "@daloyjs/core" {
interface AppState {
session: SessionContext;
}
}
export const app = new App();
app.use(
session({
secret: process.env.SESSION_SECRET!, // string OR string[]
// Defaults you don't need to think about:
// cookieName: "__Host-daloy.sid" (host-locked, no Domain, requires secure+path:"/")
// sameSite: "Lax"
// httpOnly: true
// secure: true
// ttlSeconds: 86400 (1 day)
// rolling: true (sliding expiration)
// saveUninitialized: false (no cookie until something is actually written)
}),
);
session · __Host-daloy.sid · Lax · HttpOnly · SecureTS · UTF-8 · LF
That's the whole opt-in. Every default I picked is the one I'd argue for in a code review. __Host- forces host-locked cookies (no Domain attribute, requires Secure and Path=/), which neutralizes an entire class of subdomain cookie injection. SameSite=Lax blocks the cross-site CSRF attack on navigation. HttpOnly takes the cookie off the table for JavaScript, which means an XSS bug can do a lot of harm but not specifically steal your session token.
Reading and writing session data
// src/routes/auth.ts — read and write session data
app.route({
method: "POST",
path: "/login",
operationId: "login",
body: { type: "object", properties: { username: { type: "string" } }, required: ["username"] },
responses: { 200: { description: "ok" } },
handler: async ({ body, state }) => {
// ...verify credentials...
state.session.set("userId", body.username);
// Critical: rotate the session id on privilege change to kill fixation.
await state.session.regenerate();
return { status: 200, body: { ok: true } };
},
});
app.route({
method: "GET",
path: "/me",
operationId: "me",
responses: { 200: { description: "ok" } },
handler: async ({ state }) => {
const userId = state.session.get<string>("userId");
if (!userId) return { status: 401, body: { error: "unauthenticated" } };
return { status: 200, body: { userId } };
},
});
app.route({
method: "POST",
path: "/logout",
operationId: "logout",
responses: { 204: { description: "bye" } },
handler: async ({ state }) => {
state.session.destroy();
return { status: 204 };
},
});
POST /login · GET /me · POST /logoutTS · UTF-8 · LF
The API is intentionally boring: get, set, delete, destroy, regenerate. The interesting part is what the middleware does between your handler returning and the response going out — if you wrote anything, it persists to the store and re-issues the cookie; if you didn't, saveUninitialized: falsemeans no cookie at all, which keeps your privacy banner's job small.
What's actually in the cookie
chrome://devtools · Application · Cookies
// The cookie that lands in DevTools looks like this:
//
// __Host-daloy.sid=Yv2k...QF8.h7Q9...kLm; Path=/; HttpOnly; Secure; SameSite=Lax
//
// Two halves separated by a dot:
// sid → 32 random bytes, base64url-encoded
// signature → HMAC-SHA256(sid) using the FIRST configured secret
//
// On the next request the middleware splits on '.', then:
// 1. Tries every configured secret in order to verify the signature
// (timing-safe). The session id is only "valid" if at least one signer accepts it.
// 2. If valid: load the SessionRecord from the store. If expired or missing,
// treat as no session.
// 3. If invalid: ignore the cookie entirely. A new session is NOT minted
// until something writes to it.
format: <sid>.<HMAC-SHA256 base64url>TS · UTF-8 · LF
One thing worth pointing out: the cookie carries the session id, not the session data. Everything you call set on lives in the store. The cookie is a tiny tamper-evident pointer. This is why "the store" is pluggable and why you can change backends without invalidating cookies (the signature still checks out; the new backend just has no record for that id, which is treated as "no session").
Rotating secrets without invalidating sessions
This is the feature I wish every web framework had built in, because doing it badly is how teams end up never rotating session secrets at all. Here's the entire mechanism:
secret accepts a string or an array.- The first entry in the array is used to sign new cookies.
- Every entry is tried, in order, to verify incoming cookies (timing-safe).
- Each secret must be a non-empty string of at least 16 characters — the middleware throws at construction if not.
Which lines up to a three-deploy rotation with literally zero user impact:
// .env.production — rotating a session secret in three deploys
# DEPLOY #1 (steady state): one secret, the one you've had forever.
SESSION_SECRET='a-very-long-string-at-least-16-chars-long'
# DEPLOY #2 (rotation window): NEW secret first, OLD secret second.
# All new cookies are signed with the new secret.
# All existing cookies still verify against the old secret.
# Users notice nothing.
SESSION_SECRETS='["new-secret-also-16-chars-or-more","a-very-long-string-at-least-16-chars-long"]'
# DEPLOY #3 (cleanup, after >1 ttlSeconds has passed): drop the old one.
# Any cookie still signed with the old secret naturally re-issues on next request.
SESSION_SECRETS='["new-secret-also-16-chars-or-more"]'
rotation via array — no logged-out usersTS · UTF-8 · LF
// src/app.ts — wire the array form
const secrets = JSON.parse(process.env.SESSION_SECRETS ?? "[]") as string[];
if (secrets.length === 0) {
// Fallback to the single-secret env so the rotation path is optional.
secrets.push(process.env.SESSION_SECRET!);
}
app.use(session({ secret: secrets }));
// The first entry signs new cookies.
// Every entry verifies incoming cookies.
// Drop entries after at least one full ttlSeconds window has elapsed.
wire the array formTS · UTF-8 · LF
Two days after deploy #2, every active cookie has been re-issued with the new secret (the middleware automatically re-signs on any session write, and rolling: true means a touch is a write). Deploy #3 is then safe and uneventful, which is how you want security work to feel.
regenerate() — the one line that kills session fixation
// Why regenerate() exists: session fixation in one paragraph.
//
// Attacker visits /, gets handed a session id S in a cookie.
// Attacker tricks the victim into using S (subdomain cookie injection,
// physical access to the device, an XSS that writes document.cookie, etc).
// Victim logs in. Server happily promotes S to "authenticated".
// Attacker, still holding S, is now logged in as the victim.
//
// Mitigation: after ANY privilege change (login, MFA step-up, password
// change, role assumption), call regenerate(). The middleware:
// 1. Issues a brand-new random session id S'
// 2. Carries data over (or drops it if you pass { keepData: false })
// 3. Destroys S on the server side
// 4. Sets the new cookie on the response
//
// The attacker's S is now garbage. Fixation killed.
await state.session.regenerate(); // carry data
await state.session.regenerate({ keepData: false }); // fresh start
state.session.regenerate() · use it on EVERY privilege changeTS · UTF-8 · LF
I've seen this bug in production three times in my career, and zero of those times was the team intentionally not calling an equivalent of regenerate. They all forgot. So regenerate() is a single method that is impossible to use wrong, and the docs and the type hint both nudge you to call it on privilege change. I am genuinely a little proud of how small this API turned out.
MemorySessionStore — fast tests, never production
The default store is MemorySessionStore. It's a Map with TTL handling and two test helpers (clear(), size()) on top. It is fantasticfor tests because it's synchronous, has no network, and is observable. It is not for production because the moment you have two processes (or your serverless platform scales horizontally), sessions stop being sticky.
// tests/auth.test.ts — using MemorySessionStore in tests
import { describe, it, expect, beforeEach } from "vitest";
import { App, session, MemorySessionStore } from "@daloyjs/core";
const store = new MemorySessionStore();
const app = new App().use(
session({
secret: "x".repeat(32),
store,
cookieOptions: { secure: false }, // tests over http://localhost
cookieName: "test.sid", // __Host- requires https; relax for tests
}),
);
beforeEach(() => {
store.clear(); // wipe between tests
});
it("creates exactly one record per login", async () => {
// ... drive the app through .request() ...
expect(store.size()).toBe(1);
});
MemorySessionStore · clear() between tests · size() for assertionsTS · UTF-8 · LF
The SessionStore contract
Three required methods. One optional. That's the entire surface a store has to implement to be production-ready:
@daloyjs/core · session.ts
// The entire contract. Three required methods, one optional.
//
// Sync OR async — return values or promises. The middleware awaits everything.
export interface SessionStore {
get(sid: string): SessionRecord | null | Promise<SessionRecord | null>;
set(sid: string, record: SessionRecord): void | Promise<void>;
destroy(sid: string): void | Promise<void>;
/** Optional fast-path; falls back to set() when omitted. */
touch?(sid: string, expiresAt: number): void | Promise<void>;
}
export interface SessionRecord {
data: Record<string, unknown>;
expiresAt: number; // ms since epoch
}
3 required + 1 optional · sync or asyncTS · UTF-8 · LF
Sync or async. touch() is a perf hint for rolling: true— if your backend has a cheap "just extend the TTL" operation (like Redis EXPIRE), implement it; if not, omit it and the middleware will fall back to set(). That's the whole contract. No transactions, no advisory locks, no cooperation with the cookie layer — that all stays inside the middleware.
A Redis store in twenty lines
This is the one I reach for the most. Pairs naturally with the rest of the toolkit — particularly the Redis-backed rate limiter, which can share the same connection pool.
src/stores/redis-session-store.ts
// src/stores/redis-session-store.ts — production-grade Redis adapter
import type { SessionStore, SessionRecord } from "@daloyjs/core";
import type { Redis } from "ioredis";
export class RedisSessionStore implements SessionStore {
constructor(
private readonly redis: Redis,
private readonly prefix = "sess:",
) {}
async get(sid: string): Promise<SessionRecord | null> {
const raw = await this.redis.get(this.prefix + sid);
if (!raw) return null;
const rec = JSON.parse(raw) as SessionRecord;
if (rec.expiresAt <= Date.now()) {
// Race-safe: a TTL on Redis usually beats us to it, but belt-and-braces.
await this.redis.del(this.prefix + sid);
return null;
}
return rec;
}
async set(sid: string, record: SessionRecord): Promise<void> {
const ttlSeconds = Math.max(1, Math.ceil((record.expiresAt - Date.now()) / 1000));
await this.redis.set(this.prefix + sid, JSON.stringify(record), "EX", ttlSeconds);
}
async destroy(sid: string): Promise<void> {
await this.redis.del(this.prefix + sid);
}
// Optional: avoid rewriting the whole record on every read when rolling: true.
async touch(sid: string, expiresAt: number): Promise<void> {
const ttlSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
await this.redis.expire(this.prefix + sid, ttlSeconds);
}
}
// Wire it up:
// app.use(session({
// secret: secrets,
// store: new RedisSessionStore(new Redis(process.env.REDIS_URL!)),
// }));
ioredis · TTL on SET EX · touch() via EXPIRETS · UTF-8 · LF
Redis
good for
Multi-process Node, Bun, or Deno deployments behind a load balancer. Pairs with rate-limit Redis. Atomic EXPIRE means touch() is essentially free.
watch for
One Redis = one failure domain. Use a replica or accept that a Redis outage logs everyone out. Don't store huge payloads in the session — keep it to IDs.
A Workers KV store, similar shape
src/stores/kv-session-store.ts
// src/stores/kv-session-store.ts — Cloudflare Workers KV adapter
import type { SessionStore, SessionRecord } from "@daloyjs/core";
import type { KVNamespace } from "@cloudflare/workers-types";
export class KvSessionStore implements SessionStore {
constructor(
private readonly kv: KVNamespace,
private readonly prefix = "sess:",
) {}
async get(sid: string): Promise<SessionRecord | null> {
const rec = await this.kv.get<SessionRecord>(this.prefix + sid, "json");
if (!rec) return null;
if (rec.expiresAt <= Date.now()) {
await this.kv.delete(this.prefix + sid);
return null;
}
return rec;
}
async set(sid: string, record: SessionRecord): Promise<void> {
// KV expirationTtl is in seconds. Minimum 60s on Workers KV.
const ttlSeconds = Math.max(60, Math.ceil((record.expiresAt - Date.now()) / 1000));
await this.kv.put(this.prefix + sid, JSON.stringify(record), { expirationTtl: ttlSeconds });
}
async destroy(sid: string): Promise<void> {
await this.kv.delete(this.prefix + sid);
}
// KV doesn't expose a cheap "extend TTL" — fall back to set(). The middleware
// does that automatically when touch() is omitted.
}
// In your Workers entry:
// app.use(session({
// secret: [env.SESSION_SECRET],
// store: new KvSessionStore(env.SESSION_KV),
// }));
Cloudflare Workers KV · expirationTtl min 60sTS · UTF-8 · LF
Workers KV / Vercel KV
good for
Edge deployments where you want session reads close to the user. Eventually consistent, but for sessions that's fine — you're reading your own writes by sid.
watch for
Workers KV has a 60s minimum expirationTtl and eventually-consistent global propagation. Don't use it for sub-second auth flows; do use it for long-lived sessions.
Pre-launch checklist
This is the list I literally paste into pull requests when someone wires sessions into a new app. Steal it.
# Production checklist (the list I run through before every launch):
[ ] secret is ≥ 32 random bytes, sourced from a secrets manager
[ ] secret is the ARRAY form, even with one entry — rotation is now a config change
[ ] cookieName stays __Host-daloy.sid in production (require https)
[ ] regenerate() is called on EVERY privilege change (login, MFA step-up,
password reset, role assumption, impersonation)
[ ] destroy() is called on logout AND on account deletion
[ ] saveUninitialized stays false unless you have a cookie consent banner
that explicitly allows it
[ ] store is NOT MemorySessionStore in production (it doesn't survive a
restart and doesn't share across processes — the only acceptable
Memory store is for tests)
[ ] ttlSeconds is short enough that a leaked cookie expires before your
customer notices it leaked (we use 8h for staff, 30d for shoppers)
[ ] cookieOptions.maxAgeSeconds matches ttlSeconds when you want the
cookie to die WITH the server record (otherwise the cookie outlives
the record and you serve 401s with a still-present cookie)
steal this · paste it in your launch PRTS · UTF-8 · LF
One paragraph of honest caveats
Signed cookie sessions are not magic. If an attacker gets your session secret, they can mint any session id they want — that's why the secret lives in a real secrets manager and gets rotated. If an attacker gets a user's cookie via TLS-stripping on a misconfigured subdomain, the signature won't save you — that's why __Host- + Secureare non-negotiable defaults. And if your store backend goes down, your users log out — that's why we picked an interface that supports a replica or a fallback layer if you need one.
What this middleware doesget right is the unglamorous stuff: it makes the safe path the easy path, the rotation path a config change, and the "swap backends" path a twenty-line file. That's what I wanted ten years ago and kept not having.
Where to go next
The reference for session() options, including all cookie defaults and the full SessionContext surface, is in the session docs. If you're also wiring Redis for rate limiting, the rate-limit Redis docs show how to share the connection. And the security overview stitches sessions, CSRF, CSP, and headers into one mental model.
Thanks for reading. Now go check your SESSION_SECRET is at least 32 bytes. I will wait. (If it is the word secret, I will not judge, but I will worry.)
— Devlin