Search docs

Jump between documentation pages.

Browse docs

Sessions

Think of it like… a coat-check counter. The server keeps the coat (your session data, sitting in a store). The cookie is the numbered, tamper-proof stub the browser hands back to claim it. If somebody forges the stub the signature won't match; if they steal the stub, rotating it on login or privilege change cancels the old one.

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 because it only uses WebCrypto and standard Set-Cookie headers.

Login + fixation defense
Browsersession()StoreHandler
  1. 01requestBrowsersession()Request carries the signed __Host- cookieCookie: __Host-daloy.sid=<id>.<hmac>
  2. 02notesession()BrowserBad / forged signature is rejected, no data loadedHMAC-SHA256 mismatch to empty session
  3. 03requestsession()StoreValid signature loads the server-side payloadstore.get(id)
  4. 04asyncHandlersession()On login: regenerate() issues a fresh idold store record destroyed to defeat fixation
  5. 05responsesession()BrowserPersist once in onSend, re-emit the cookieSet-Cookie: HttpOnly; Secure; SameSite=Lax
The cookie only carries a signed id, never the payload. A tampered stub fails the HMAC check and loads nothing; on login regenerate() swaps the id and drops the old store record, so a fixated session id is useless after authentication.

Quick start

ts
import { App, rotateSession, 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.use(rotateSession({ watch: ["userId", "roles", "tenantId"] }));

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 - forces Secure, Path=/, no Domain.
  • cookieOptions: { secure: true, httpOnly: true, sameSite: "Lax", path: "/", maxAgeSeconds: 86_400 }.
  • store: a fresh MemorySessionStore() per app. Replace with a KV-backed store in production.
  • rolling: true - every authenticated request slides the expiry and re-emits Set-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 own generateId to customize.

The session API

Inside a handler, ctx.state.session exposes:

  • id: string - current session id.
  • data: Record<string, unknown> - payload object. Mutating it through set / delete marks the session dirty and triggers a single store write in onSend.
  • 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 a Set-Cookie with Max-Age=0.

Automatic rotation on privilege changes

rotateSession() watches privilege-bearing session values and calls session.regenerate() after the handler if they changed. The default watch list covers userId, tenantId, roles, scopes, and isAdmin. If a handler already calls regenerate(), the helper skips itself.

ts
app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(rotateSession({ watch: ["userId", "roles", "tenantId"] }));

app.route({
  method: "POST",
  path: "/admin/promote",
  responses: { 200: { description: "ok" } },
  handler: async ({ state }) => {
    state.session.set("roles", ["admin"]);
    return { status: 200 as const, body: { ok: true } };
  },
});

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.

ts
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.

ts
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.

ts
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 secret array as append-only: when you rotate, prepend the new key and keep the previous entry until the longest plausible session has expired.
  • The default MemorySessionStore is per-process - it is suitable for tests and single-instance deployments only. Use a KV/Redis-shaped store across replicas.