Skip to content

Cookies & Sessions (s200/cookies, s200/session) ​

Cookie reading/writing/signing as pure functions, and server-side sessions over a signed opaque id.

Cookies (s200/cookies) ​

Writing appends a proper Set-Cookie header (repeat calls stay separate headers, never comma-joined); call it once the response exists — the natural spot is the unwind, after await next():

ts
use(app, async (ctx, next) => {
  await next();
  setCookie(ctx, 'theme', 'dark', { httpOnly: true, sameSite: 'lax', maxAge: 86400 });
});
await setSignedCookie(ctx, 'sid', userId, SECRET);          // + sid.sig HMAC partner
const sid = await getSignedCookie(ctx, 'sid', SECRET);      // undefined unless verified
FunctionMeaning
getCookie(ctx, name)Reads one cookie (decoded); undefined for a missing cookie or a malformed pair
setCookie(ctx, name, value, options?)Appends a Set-Cookie header; throws when no response exists yet
signCookie(value, secret)value.sig — HMAC-SHA256 signature over the value
verifyCookieSignature(value, sig, secret)Constant-time verification; false on mismatch
timingSafeEqual(a, b)Constant-time string comparison — the primitive s200/auth verify callbacks should use
setSignedCookie(ctx, name, value, secret, options?)Sets name=value plus the name.sig partner cookie
getSignedCookie(ctx, name, secret)The value when the signature verifies, else undefined

CookieOptions:

FieldDefaultMeaning
maxAge—Lifetime in seconds → Max-Age
expires—Absolute expiry → Expires (RFC 1123)
domain / path— / '/'Scope
secure / httpOnly— / falseCookie flags
sameSite—'strict' | 'lax' | 'none'
partitioned—Chrome CHIPS — emits the Partitioned attribute

Cookie names live in the RFC 6265 token alphabet (stricter than values); values are percent-decoded on read.

Sessions (s200/session) ​

createSession({ secret, store? }) returns a middleware (plus close() for the default store's sweep timer). The client only ever holds an opaque signed id (HMAC via the cookies battery — one signing convention framework-wide); data lives server-side in the pluggable store. Tampered, missing, or expired ids all look identical: a fresh empty session, never an error.

Mutations (set/delete/clear/touch) persist and (re)issue the cookie on the unwind; destroy() deletes from the store and expires the cookie; regenerate() rotates to a fresh id with the old one deleted on the spot; read-only requests write nothing — no store write, no Set-Cookie.

ts
const { middleware, close } = createSession({ secret: SECRET, cookie: { maxAge: 3600 } });
use(app, middleware);
get(app, '/', (ctx) => {
  const session = ctx.state.session as Session;
  session.set('user', 'alice');            // persisted + cookie issued on unwind
  return json(ctx, { visits: session.get('visits') });
});

SessionOptions:

FieldDefaultMeaning
secret(required)HMAC secret — the same key material the cookies battery signs with
cookie—SessionCookieOptions (below)
storein-process map, lazy expiry + periodic unref'd sweepSessionStore (below) — the Redis SET … EX shape
key'session'ctx.state slot for the session object
nowDate.nowClock override (tests, deterministic replays)

SessionCookieOptions: { name? ('s200.sid', signature partner .sig), maxAge? (86_400), httpOnly? (true), sameSite?, path?, secure?, domain? }.

SessionStore: get(id), set(id, data, ttlSeconds), delete(id) — each may return a value or a promise; set receives the TTL the cookie advertises so a shared store expires data together with its cookie.

Session (hung on ctx.state[key]):

MemberMeaning
idThe restored session's id; undefined until the unwind mints one
isNewStable for the request — true when no valid cookie + store hit restored data
get(key) / set(key, value) / delete(key) / clear()Data access; mutations persist on the unwind
touch()Renews the expiry of an existing session (no-op on a new one)
regenerate(keepData?)Session-fixation defense at login/privilege change: rotates to a fresh id (old id deleted from the store immediately — a stolen cookie dies even if the response never lands) and the unwind persists + (re)issues the cookie under the new one. Default drops the data (express-session semantics); regenerate(true) keeps it. isNew keeps its stable value; no-op after destroy()
destroy()Deletes from the store; the unwind expires the cookie. Terminal — mutations after destroy() are discarded

SessionData: Record<string, unknown> — plain JSON-shaped records, opaque to the store.

Login rotation ​

regenerate() is the session-fixation defense: an id an attacker fixed onto the browser before login dies in the store the moment login rotates it. Pairs naturally with the CSRF battery's bind — folding the session id into the token's HMAC retires tokens minted for the old id:

ts
const csrf = createCsrf({
  secret: SECRET,
  bind: (ctx) => (ctx.state.session as Session).id ?? '',
});

post(app, '/login', async (ctx) => {
  const session = ctx.state.session as Session;
  await session.regenerate();   // fresh id, data dropped
  session.set('user', 'alice'); // the upgrade itself
  await csrf.issueToken(ctx);   // fresh token bound to the new id
  return json(ctx, { ok: true });
});

Released under the MIT License.