Sessions
createSessionManager keeps session data on the server and puts only an opaque id in a signed cookie.
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.
import { defineEnv } from "@shadow-js/dusk/server";
export const env = defineEnv({
SESSION_SECRET: { type: "string", minLength: 32, secret: true },
});
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",
});
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 });
};
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 });
};
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.
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:
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:
[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:
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.
Cookie name#
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).
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);
},
};
}