[]previous and next page

Sessions

createSessionManager keeps session data on the server and puts only an opaque id in a signed cookie.

For agentsUpdated

createSessionManager keeps session data on the server and puts only an opaque id in a signed cookie. The cookie is HttpOnly; Secure; SameSite=Lax, except that Secure is left off in development over plain http to localhost. On https its default name is __Host-shadow_session: the browser then accepts it only from a response of your exact host over https, with Path=/ and no Domain, so a sibling subdomain cannot plant or overwrite it. A cookie whose signature does not verify counts as no cookie.

Option Default Meaning
secrets required 32+ characters each; the first signs, all verify
store bounded memory store where records live (SessionStore)
cookieName, cookie __Host-shadow_session when the cookie is Secure, otherwise shadow_session; no cookie options name and domain, path, sameSite, secure
idleTimeoutSeconds 1800 (30 min) a session unused for this long ends
absoluteTimeoutSeconds 604 800 (7 days) a session ends this long after creation, however active
rotateAfterSeconds 900 (15 min) the id changes on the first request after this
rotationGraceSeconds 10 after a timed rotation the previous id keeps working this long, for requests already in flight. regenerate() does not use it

Use it in two places: the middleware saves the session and sets the cookie after the request (also when the route failed: next() returns the error response, see Middleware), and sessions.get(event) loads the session inside a route, loader or server function. Without the middleware, changes are not saved. Sign the user in with signIn(session, values), which gives the session a new id first (see Sign-in), and session.destroy() signs out. A new session with no data sets no cookie and writes nothing.

src/env.tsts
import { defineEnv } from "@shadow-js/dusk/server";

export const env = defineEnv({
  SESSION_SECRET: { type: "string", minLength: 32, secret: true },
});
src/session.tsts
import { createSessionManager } from "@shadow-js/dusk/server";
import { env } from "./env";

const { SESSION_SECRET } = env.parse(process.env).all;

export const sessions = createSessionManager({
  secrets: [SESSION_SECRET],
  cookieName: "__Host-session",
});
src/routes/api/visit.tsts
import { json, type RequestEvent } from "@shadow-js/dusk/server";
import { sessions } from "../../session";

export const GET = async (event: RequestEvent) => {
  const session = await sessions.get(event);
  const visits = (session.get<number>("visits") ?? 0) + 1;
  session.set("visits", visits);
  return json({ visits });
};
src/routes/api/login.tsts
import { json, type RequestEvent } from "@shadow-js/dusk/server";
import { sessions } from "../../session";

export const POST = async (event: RequestEvent) => {
  const session = await sessions.get(event);
  session.regenerate();
  session.set("userId", "u1");
  return json({ ok: true });
};
src/routes/api/account.tsts
import { HttpError, json, type RequestEvent } from "@shadow-js/dusk/server";
import { sessions } from "../../session";

export const GET = async (event: RequestEvent) => {
  const userId = (await sessions.get(event)).get<string>("userId");
  if (userId === undefined) throw new HttpError(401, "Sign in first");
  return json({ userId });
};

export const DELETE = async (event: RequestEvent) => {
  (await sessions.get(event)).destroy();
  return json({ ok: true });
};

Sign-in#

Sign the user in with signIn(session, values). It gives the session a new id and then stores values, so the id the browser held before the sign-in is dead the moment the request ends, and an attacker who planted that id (session fixation) gets nothing. Data the session already held, such as an anonymous cart, moves to the new id. Use session.destroy() to sign out.

src/routes/api/login.tsts
import { apiRoute, json, signIn } from "@shadow-js/dusk/server";
import { sessions } from "../../session";
import { checkPassword } from "../../users";

export default apiRoute({
  POST: async (event) => {
    const { email, password } = await event.request.json();
    const user = await checkPassword(email, password);
    if (user === undefined) return json({ error: "wrong email or password" }, { status: 401 });
    signIn(await sessions.get(event), { userId: user.id });
    return json({ ok: true });
  },
});

signIn is a short way to write session.regenerate() followed by one session.set per value. If you prefer the explicit form, call regenerate() before you set the identity, or anywhere in the same request:

ts
const session = await sessions.get(event);
session.regenerate();
session.set("userId", user.id);

The development warning

Forgetting regenerate() is silent, so in development Dusk says so. When a request writes one of the identity keys userId, user, uid, sub, accountId (also user_id and account_id; case does not matter) into a session that did not hold it, and the id was not regenerated in that request, the manager warns once for the request:

text
[dusk] session.set("userId") wrote an identity on a session whose id was not regenerated in this request: an id planted
before the sign-in would stay valid (session fixation). Sign in with signIn(session, { userId: … }) or call
session.regenerate() first.

The check runs when the middleware saves the session, so set followed by regenerate() in the same request is fine. Updating an identity the session already holds (a profile refresh that rewrites user) is not a sign-in and does not warn. The warning appears only in development (event.dev and a development build); a production build carries no trace of it. It goes to console.warn with the [dusk] prefix, like the handler's configuration warnings. To collect it, for a logger, pass onWarning:

ts
export const sessions = createSessionManager({
  secrets: [env.SESSION_SECRET],
  onWarning: (message) => logger.warn(message),
});

If your app signs in through another mechanism and the key name collides (a user key that is not an identity), store it under a different key; the warning is a development aid, not a check that blocks a request.

The lifetimes are measured against the time the server last saw the session. To avoid a write on every request, the stored last-seen time is refreshed only when more than a quarter of the idle timeout has passed, so with the defaults a session ends between 22.5 and 30 minutes after its last request. The absolute limit and the rotation interval are exact. The two ways an id changes treat the old id differently. A timed rotation (rotateAfterSeconds) stores the previous id a second time for rotationGraceSeconds with the current data, so a request that was already in flight with the old cookie still finds its session. regenerate() is for a change of privilege (sign-in), and there is no grace copy: the old id is deleted when the request ends, so a cookie that an attacker planted before the login is worth nothing afterward. The cost is that a request already in flight with the old cookie when the user signs in is treated as anonymous.

The default name follows how the cookie is written. When the cookie carries Secure (every https request, and every production request) and meets the __Host- rules (Path=/, no Domain), the name is __Host-shadow_session. In development over plain http on localhost, where Secure is off so that the browser keeps the cookie, the name stays shadow_session. If you set cookie.domain, a cookie.path other than / or cookie.secure: false, the cookie cannot be a __Host- cookie, so the manager keeps the plain name instead of throwing. A cookieName you pass is always used as written: that includes a __Host- name combined with a domain, which still fails with the cookie error, because you asked for something the browser would reject.

The plain name is never read on a request that uses the __Host- name. A cookie called shadow_session that arrives over https (planted by another subdomain, say) is simply not a session. Moving an existing app from the old default to this one signs the current visitors out once; to keep the old name, pass cookieName: "shadow_session".

A shared session store#

The default store lives in the process's memory: at most 10 000 sessions, least recently used evicted first, expired records dropped on read, and gone on restart. With more than one server process, or if sessions must survive a deploy, implement SessionStore (three methods; set receives the time to live in seconds, which the store must honor) on Redis or a database. This type-checked sketch targets a Redis-style client; the record is JSON, so session values must be JSON-serializable (the memory store accepts anything structuredClone does).

src/redis-store.tsts
import type { SessionRecord, SessionStore } from "@shadow-js/dusk/server";

/** The three calls of a Redis client the store needs; ioredis and node-redis both offer them. */
export interface KeyValueClient {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, mode: "EX", seconds: number): Promise<unknown>;
  del(key: string): Promise<unknown>;
}

function isRecord(value: unknown): value is SessionRecord {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof Reflect.get(value, "createdAt") === "number" &&
    typeof Reflect.get(value, "rotatedAt") === "number" &&
    typeof Reflect.get(value, "lastSeenAt") === "number" &&
    typeof Reflect.get(value, "data") === "object"
  );
}

export function createRedisSessionStore(client: KeyValueClient, prefix = "sess:"): SessionStore {
  return {
    async get(id) {
      const raw = await client.get(prefix + id);
      if (raw === null) return undefined;
      const parsed: unknown = JSON.parse(raw);
      return isRecord(parsed) ? parsed : undefined;
    },
    async set(id, record, ttlSeconds) {
      await client.set(prefix + id, JSON.stringify(record), "EX", ttlSeconds);
    },
    async delete(id) {
      await client.del(prefix + id);
    },
  };
}