Sessions
DaloyJS ships a small, runtime-portable session() middleware: a signed__Host- cookie carries the session id, the payload lives in a pluggable SessionStore (in-memory by default; KV / Redis-shaped stores plug in directly), and per-request mutations are exposed on ctx.state.session. There are no adapter-specific code paths - the same middleware runs on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge because it only uses WebCrypto and standard Set-Cookie headers.
Quick start
import { App, session } from "@daloyjs/core";
declare module "@daloyjs/core" {
interface AppState {
session: import("@daloyjs/core").SessionContext;
}
}
const app = new App();
app.use(session({ secret: process.env.SESSION_SECRET! }));
app.route({
method: "POST",
path: "/login",
operationId: "login",
responses: { 200: { description: "ok" } },
handler: async ({ state }) => {
// After authenticating the user, rotate the id to defend against fixation
// and write the user payload. Mutating ctx.state.session.data is enough -
// the middleware persists changes once per request in onSend.
await state.session.regenerate();
state.session.set("userId", "u_123");
return { status: 200 as const, body: { ok: true } };
},
});
app.route({
method: "GET",
path: "/me",
operationId: "me",
responses: { 200: { description: "ok" } },
handler: async ({ state }) => ({
status: 200 as const,
body: { userId: state.session.get<string>("userId") ?? null },
}),
});
app.route({
method: "POST",
path: "/logout",
operationId: "logout",
responses: { 204: { description: "logged out" } },
handler: async ({ state }) => {
state.session.destroy();
return { status: 204 as const };
},
});Defaults
Every option is conservative by default, with explicit error messages when a setting would silently weaken security (for example, a non-/ path on a __Host- cookie or SameSite=None without Secure).
cookieName:__Host-daloy.sid- forcesSecure,Path=/, noDomain.cookieOptions:{ secure: true, httpOnly: true, sameSite: "Lax", path: "/", maxAgeSeconds: 86_400 }.store: a freshMemorySessionStore()per app. Replace with a KV-backed store in production.rolling:true- every authenticated request slides the expiry and re-emitsSet-Cookie.saveUninitialized:false- anonymous traffic that never touches the session never writes a cookie or store record.generateId:crypto.randomUUID()when available; otherwise a base64url-encoded 32-byte random string. Pass your owngenerateIdto customize.
The session API
Inside a handler, ctx.state.session exposes:
id: string- current session id.data: Record<string, unknown>- payload object. Mutating it throughset/deletemarks the session dirty and triggers a single store write inonSend.get<T>(key)/set(key, value)/delete(key).regenerate({ keepData? })- issues a new id, destroys the previous store record, and (by default) carries the existing payload over. Call it on login and on privilege escalation to defend against session fixation.destroy()- drops server-side state and emits aSet-CookiewithMax-Age=0.
Key rotation
Pass an array to secret. The first entry is always used to sign new cookies; any later entry can verify (so older clients keep working until their next request) and triggers a transparent re-sign on the way out.
session({
secret: [process.env.SESSION_SECRET_CURRENT!, process.env.SESSION_SECRET_PREVIOUS!],
});Pluggable store
Implement SessionStore against any KV/Redis-shaped backend. Methods may return synchronously or via a Promise - DaloyJS always awaits them, so a fully async store works without changes.
import type { SessionStore } from "@daloyjs/core";
const kvStore: SessionStore = {
async get(id) {
const raw = await KV.get(id);
return raw ? (JSON.parse(raw) as { data: Record<string, unknown>; expiresAt: number }) : null;
},
async set(id, record) {
const ttlSeconds = Math.max(1, Math.ceil((record.expiresAt - Date.now()) / 1000));
await KV.put(id, JSON.stringify(record), { expirationTtl: ttlSeconds });
},
async destroy(id) {
await KV.delete(id);
},
// Optional: implement touch() to slide the expiry without rewriting the payload.
async touch(id, expiresAt) {
const raw = await KV.get(id);
if (!raw) return;
const ttlSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
await KV.put(id, raw, { expirationTtl: ttlSeconds });
},
};
app.use(session({ secret: process.env.SESSION_SECRET!, store: kvStore }));Standalone signing helpers
The same HMAC-SHA256 primitives that power the cookie are exported as signValue(value, secret) and verifySignedValue(signed, secret) (which accepts a single secret or an array for rotation). Use them for ad-hoc cookies, magic links, or any other place you need a tamper-evident token without standing up the full session pipeline.
import { signValue, verifySignedValue } from "@daloyjs/core";
const signed = await signValue("user_123", process.env.LINK_SECRET!);
const original = await verifySignedValue(signed, process.env.LINK_SECRET!);
// original === "user_123" or null if tampered / wrong secret.Security notes
- The session cookie is HttpOnly by default - it is unreadable from JavaScript. Pair it with the
csrf()middleware on mutating routes. - Always rotate the id with
regenerate()on login and privilege escalation. - Use
destroy()on logout to invalidate both the cookie and the store record. - Treat the
secretarray as append-only: when you rotate, prepend the new key and keep the previous entry until the longest plausible session has expired. - The default
MemorySessionStoreis per-process - it is suitable for tests and single-instance deployments only. Use a KV/Redis-shaped store across replicas.