Search docs

Jump between documentation pages.

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

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

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.