Skip to content

Cookie Sessions (@zebra-web/session)

@zebra-web/session provides signed-cookie server-side sessions: HMAC-SHA256-signed sid cookies, a pluggable SessionStore (in-memory by default), rolling TTL renewal, and session-fixation protection. It also bridges into core's session-scoped DI via its resolver.

Install

sh
bun add @zebra-web/session

Quick start

ts
import { Zebra } from "@zebra-web/core";
import { sessionMiddleware } from "@zebra-web/session";

const session = sessionMiddleware({
  secret: "a-long-random-secret",
  cookie: { httpOnly: true, sameSite: "lax", maxAge: 7 * 24 * 60 * 60, path: "/" },
});

const app = new Zebra({
  session: { resolver: session.resolver, wsSession: session.wsSession, ttl: 30 * 60 * 1000 },
});

app.use(session);

app.get("/counter", async (req) => {
  const s = getSession(req)!;
  const count = (await s.get<number>("count")) ?? 0;
  await s.set("count", count + 1);
  return { count: count + 1 };
});

The key wiring:

  1. sessionMiddleware({ secret, cookie?, store? }) returns the middleware object.
  2. Its .resolver goes into new Zebra({ session: { resolver, ttl } }) — this makes session-scoped DI work under the same session id (core doesn't depend on the session package; the resolver is the bridge).
  3. .wsSession goes into session: { wsSession } — WebSocket connections get a session handle (see WebSocket).
  4. app.use(session) mounts the middleware: it puts a read/write RequestSession on req.ctx.session and persists on the response path.

Two entry points for the same options: ZebraOptions accepts the session resolver/ttl either nested (session: { resolver, ttl }, recommended — it also carries wsSession) or at the top level (sessionResolver, sessionTtl — kept for compatibility; the nested form wins where both are given for wsSession, and opts.sessionResolver ?? opts.session?.resolver resolves the resolver). The middleware's .resolver property is the cookie-parsing function to pass through either entry point.

RequestSession API

getSession(req) returns the current request's session handle (undefined when the middleware didn't run):

ts
interface RequestSession {
  readonly id: string;      // verified session id (fresh for first-time visitors)
  readonly isNew: boolean;  // whether this request created the session
  get<T>(key: string): Promise<T | undefined>;
  set(key: string, value: unknown): Promise<void>;
  delete(key: string): Promise<void>;
  has(key: string): Promise<boolean>;
  data(): Promise<Record<string, unknown>>; // shallow copy
  flush(): Promise<void>;   // persist now (the middleware also persists at response end)
  destroy(): Promise<void>; // destroy: remove data + expiring Set-Cookie
}
  • Data is lazily loaded: the first get/set pulls from the store, cached for the rest of the request.
  • set/delete mark the session dirty; persistence happens at response end (even on error). A brand-new visitor that never wrote data writes nothing (no store pollution).
  • After destroy() the handle is inert: further mutations are not persisted, and the response carries Set-Cookie: sid=; Max-Age=0 so the client drops the cookie.

Persistence semantics

ScenarioBehavior
New session + no writesstore untouched (zero-cost anonymous requests)
New session + writesstore.set(id, data) + Set-Cookie (signed)
Existing session + writesstore.set(id, data)
Existing session + no writesstore.touch(id) — rolling TTL renewal
destroy()store.destroy(id) + expiring cookie; never revived

Persistence runs after next() (including the handler-threw path, as long as the session is not destroyed) — see "TTL ownership" below.

  • Default cookie name sid, path /.
  • Value = HMAC-SHA256-signed id. parseSignedCookie verifies the signature; a tampered cookie is treated as anonymous.
  • The default cookie carries HttpOnly + SameSite=Lax. Opt out with preset: "plain" (a flag-free cookie — the original default), or override per attribute (explicit attributes always win over the preset):
ts
sessionMiddleware({
  secret,
  // default: HttpOnly + SameSite=Lax
  cookie: { preset: "plain" },             // no flags
  // or explicit: cookie: { httpOnly: true, sameSite: "strict", secure: true }
});

SECURE_COOKIE is the frozen { httpOnly: true, sameSite: "lax" } constant applied by default.

Cookie maxAge is in seconds and must be finite: NaN, Infinity, and -Infinity throw TypeError during serialization. Positive fractions are rounded down before computing both Max-Age and Expires (1.5 becomes 1). Zero, negative values, and positive values below one second emit Max-Age=0 with an epoch Expires to delete the cookie. A positive normalized value that puts Expires outside JavaScript's Date range also throws TypeError.

Session-fixation protection

  • The signature only proves the cookie is genuine, not that the session is alive. Both the resolver and openSession consult the store before reusing an id: a verified id with no store record (destroyed or TTL-expired) is treated as a new visitor — a fresh sid + cookie replace the stale one instead of resurrecting the old session.
  • This keeps core's session DI scope consistent with the middleware's data layer: a destroyed session revives neither data nor DI scope.
  • MemoryStore uses a short-lived tombstone so an in-flight request's set cannot resurrect a destroyed session.

SessionStore interface and default implementation

ts
interface SessionStore {
  get(id: string): Promise<unknown | undefined>;
  set(id: string, data: unknown): Promise<void>;
  touch(id: string, ttl?: number): Promise<void>;
  destroy(id: string): Promise<void>;
}
  • MemoryStore({ ttl }) — default, Map-backed, lazy sweep (at most SWEEP_BUDGET entries per access), no timers, no leaks; TTL in ms.
  • Roll your own backend (Redis / Postgres): implement this interface. @zebra-web/redis ships RedisSessionStore (see Redis).

MemoryStore and RedisSessionStore require finite millisecond values for constructor ttl and per-call touch(id, ttl) overrides. NaN, Infinity, and -Infinity fail with TypeError; invalid touches reject before any store read, write, or expiry sweep and leave existing data and expirations unchanged. Omitting the override uses the store's configured TTL. Finite overrides retain their value, including fractions; zero or negative overrides expire the session immediately.

TTL ownership

Two independent TTLs:

  • The store TTL owns the data: a session id is alive iff the store holds a record. After expiry, the cookie is dead and the data is gone.
  • Core's sessionTtl only reclaims the DI container: app.disposeSession(id) clears the container and timer, never touching store data.

To reclaim both immediately (logout), combine session.destroy() (store layer) with app.disposeSession(id) (container layer).

Logout pattern

ts
import { HttpError } from "@zebra-web/core";
import { getSession } from "@zebra-web/session";

z.post("/logout", async (req) => {
  const s = getSession(req);
  if (!s) throw new HttpError(401, "unauthorized", "No session");
  await s.destroy();
  // response carries the expiring Set-Cookie; the store record is gone,
  // so the old cookie can no longer revive the session
  return { ok: true };
});

WebSocket sessions

The .wsSession hook returned by sessionMiddleware attaches a connection-level session handle to ws.data.session at upgrade time (undefined for anonymous connections — an upgrade response cannot send Set-Cookie, so no orphan sessions are fabricated). WebSockets have no HTTP response path for automatic persistence: write explicitly with await session.flush(). See WebSocket.

Next steps

Built with VitePress · MIT Licensed