go-bananas/docs/adr/0003-stateful-sessions.md
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

4.3 KiB

ADR 0003: Stateless signed session cookies

  • Status: accepted
  • Date: 2026-10-04

Context

The administration API needs a session. The service may run more than one replica behind a load balancer, and the login callback may be handled by a different pod from the request that started it.

Decision

A session is a signed JSON payload in an HttpOnly, Secure, SameSite cookie. There is no server-side session table.

Rationale

The load-balancer problem. With a server-side session store, a session created by one replica must be readable by another. That means either sticky sessions (which lose the pod on every rollout) or a shared store (Redis or a database table), which is the operational surface ADR 0001 set out to avoid. A signed cookie removes the question entirely.

The transaction state has the same problem. The OIDC flow carries a PKCE verifier and a return path from the authorize redirect to the callback. Keeping that in server-side state reintroduces the same requirement. It is a second signed cookie, read and consumed exactly once.

The lifetime is short. An administrative session is eight hours by default. The cost of not being able to revoke a session before it expires is bounded by that window.

What it costs. Two things, stated plainly:

  1. Revocation. A stolen session cannot be invalidated before it expires. The mitigations are an eight-hour lifetime and, for a full invalidation, rotating the signing key, which logs out every session at once. Rotating is a deployment, not a button.
  2. Payload size. The cookie carries identity and roles. That is a few hundred bytes, comfortably inside the 4 kB browser limit. A larger claim set would push against it.

What was rejected

A server-side session table in SQLite. Would work, and would add revocation. Rejected because it adds a table, a write on every login, a sweep job, and a migration path for a problem the eight-hour window already bounds.

Redis. Would work. Rejected as operational surface for the same reason the render cache stayed process-local.

JWT as a bearer token in localStorage. Rejected on security grounds. A bearer token in localStorage is readable by any script on the page, so one XSS bug becomes a credential theft. An HttpOnly cookie is not readable by script, and SameSite=Lax plus the session cookie's scope mean a cross-site form post cannot ride it. The session is a cookie precisely so that it is not script-accessible.

Signed but not encrypted. The payload is readable by the user holding the cookie. It contains a subject, an email, a display name and roles — no secrets. It is signed, not encrypted, so that a client can read it and debugging is possible. Encrypting it would add a key for no confidentiality gain on this content.

Implementation requirements

Every one of these is a security property, and each has a test:

  • HMAC-SHA256 signature, compared with subtle.ConstantTimeCompare. A byte-by-byte early return leaks the correct prefix length to an attacker measuring responses.
  • HttpOnly, so script cannot read it.
  • Secure in any non-development environment, enforced by configuration validation.
  • SameSite, defaulted to Lax. None requires Secure, enforced by validation, because browsers reject the combination and the session would silently never survive a redirect.
  • Payload expiry, checked server-side on every request. The cookie's own Max-Age is a client hint, not a control.
  • An absent or invalid role set defaults to viewer. A cookie carrying no roles must never be treated as carrying privileges.
  • The signing secret is at least 32 bytes, and an ephemeral one is generated in development rather than requiring configuration that does not matter locally.

Consequences

Good

  • Any replica can authenticate any request, with no stickiness and no shared store.
  • A rollback of the Deployment does not log anyone out.
  • Nothing to sweep, nothing to migrate.

Bad

  • No per-session revocation. Bounded by the session lifetime.
  • Rotating the key logs out everyone, not one session.

Neutral

  • Logout must clear the cookie rather than delete a record, so a client that keeps a copy can still replay until expiry. Mitigated by the short lifetime.
  • The payload is readable by its holder. Acceptable because it contains no secrets.