zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Cookies & SessionsSupported

Cookies are read in a handler from ctx.headers.cookie, and set by returning a response that carries set-cookie. There is no session middleware; a session store is a provider you inject.

export function parseCookies(header: string | undefined): Readonly<Record<string, string>> {
  const out: Record<string, string> = {};
  for (const part of (header ?? '').split(';')) {
    const index = part.indexOf('=');
    if (index <= 0) continue;
    out[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim());
  }
  return out;
}
@Get('/me')
async me(ctx: Ctx<Record<never, string>, unknown>) {
  const sid = parseCookies(ctx.headers.cookie).sid;
  if (sid === undefined) throw new ValidationError('not authenticated', []);
  return this.sessions.load(sid);
}

Split on the first = only. A cookie value can contain = (base64 padding, for instance), and split('=') truncates it — which produces an intermittently invalid session id that is very hard to debug.

Setting one#

Return the cookie from the login handler. json, text and respond all take response headers, and the router passes a response they built through untouched:

@Post('/login')
async login(ctx: Ctx<Record<never, string>, Credentials>) {
  const sid = await this.sessions.create(await this.users.authenticate(ctx.body));
  return json({ ok: true }, {
    headers: { 'set-cookie': `sid=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800` },
  });
}

Log out the same way, with an expired cookie: sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0. The attributes on the clearing cookie must match the ones it replaces, or the browser keeps both.

One set-cookie per response. WebResponse.headers is a Record<string, string>, and set-cookie is the one header that cannot be safely folded into a single comma-separated value. If a response genuinely needs to set two cookies, the adapter has to append the second — but the case is rare enough that reaching for it is usually a sign the second value belongs in the session record instead.

A bearer token in the Authorization header remains a reasonable alternative, and avoids CSRF entirely rather than mitigating it with SameSite.

The attributes, and why each one#

sid=…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800
AttributeWhy
HttpOnlyJavaScript cannot read it, so XSS cannot exfiltrate the session
Securenever sent over plaintext HTTP
SameSite=Laxnot sent on cross-site state-changing requests — the CSRF defence
Path=/scope explicitly rather than relying on the request path
Max-Agebounded lifetime; a session cookie with no expiry lives until the browser closes

All five. HttpOnly and Secure are the two that turn a survivable bug into a breach.

Do not put anything but an opaque identifier in a cookie. A cookie containing a user id, a role or JSON is client-controlled data that arrives back looking authoritative — the classic privilege escalation. If you must, sign it and verify the signature with timingSafeEqual.

A session store as a provider#

import { createToken } from '@zmdb/app/di';

export interface SessionStore {
  load(sid: string): Promise<Session | undefined>;
  create(userId: number): Promise<string>;
  destroy(sid: string): Promise<void>;
}

export const SESSIONS = createToken<SessionStore>('SESSIONS');
@Module({
  providers: [{ token: SESSIONS, useFactory: () => new RedisSessionStore(env.REDIS_URL) }],
  controllers: [AuthController],
})
export class AuthModule {}
@Controller('/auth')
export class AuthController {
  @Inject(SESSIONS) private readonly sessions!: SessionStore;
}

@Inject is a field decorator — container.build calls new Ctor(), so constructor injection does not exist. See Dependency Injection.

Behind a token, so a test substitutes an in-memory store:

createTestApp(AppModule, { overrides: [{ token: SESSIONS, useValue: new MemoryStore() }] });

Session ids#

const sid = randomBytes(32).toString('base64url');

randomBytes, never Math.random() — predictable session ids have been the root cause of many account-takeover vulnerabilities.

Store a hash of the id, so a database leak does not hand over live sessions. Rotate the id on login and on privilege change, or a session fixated before authentication remains valid after it.

Set an absolute expiry as well as an idle one. A session that refreshes forever never expires, which turns one stolen cookie into permanent access.

Sessions in the database#

If you already have Postgres, you do not need Redis:

import type { PrimaryKey, References, Serial, Sql, Table } from '@zmdb/core/tags';

export interface Session extends Table<'sessions'> {
  id: number & Sql<'integer'> & Serial & PrimaryKey;
  token_hash: string & Sql<'text'>;
  user_id: number & Sql<'integer'> & References<'users.id'>;
  expires_at: Date & Sql<'timestamp'>;
}

Create an explicit unique index on token_hash with createIndexDdl, then delete expired rows on a schedule — see Indexes & Constraints and Task Scheduling. One fewer system to run, one more query per request.

---

See also: Authentication · CSRF · Dependency Injection