Skip to content
sign in

==> read the decisions

ADR-0005: The door picks the credential: an opaque session for browsers, tokens for the rest

Status: Accepted (revised 2026-09-30, see below). First recorded as "JWT is the format; the client picks the transport"; the revision replaced that decision, and no JWT is issued or accepted today. Amended by ADR-0020 (2026-10-15): the API's OAuth endpoints for MCP, registration by metadata document only.

Two credential styles were on the table - an opaque session cookie and a bearer JWT - and it was unclear whether both were needed. Framing it as "cookie or JWT" is a category error: the cookie is a transport and the JWT is a format, so they are orthogonal.

Decision (original, superseded by the revision below)

Section titled “Decision (original, superseded by the revision below)”
  • One credential and one format: JWT signed with Ed25519, via jose.
  • Two transports, chosen by the CLIENT, never by the endpoint: a browser gets an httpOnly cookie; a client with no cookie jar sends Authorization: Bearer.
  • Verification happens where the token is consumed. app. verifies the signature for rendering. api. is authoritative: signature, audience, sid revocation. mcp. verifies with aud: mcp.
  • The private key exists only on prospit, where login mints. Skaia holds only the public key, so a compromised frontend host can verify but cannot mint.
  • Session tokens carry sid, and the sessions row is the revocation list. Access is short-lived.
  • One key, one verification path, one library, one logout semantic.
  • The CDN verifies nothing and must never receive a token. That is the concrete reason the cookie stays host-scoped to app. unless the browser needs to call api. directly, in which case the widening is deliberate.
  • A cookie is attached automatically and is therefore CSRF-able; a bearer token is attached deliberately and has no CSRF surface but is only as secret as its storage. That asymmetry is why the split follows the client type rather than the route.
  • aud keeps a leaked MCP token from being replayed as a session.
  • HS256: it would hand the frontend host minting capability for no benefit.
  • Opaque sessions only: still correct and revocable, but rendering a page would need a round trip to prospit.

Revision (2026-09-30): opaque sessions, personal access tokens, and the door decides

Section titled “Revision (2026-09-30): opaque sessions, personal access tokens, and the door decides”

What changed, and why:

  • The browser session is an opaque token, not a JWT. Every request looked up the session row anyway to honour revocation, and a signature cannot revoke, so the JWT bought nothing but a key to manage. The cookie is __Host-cs_session: httpOnly, Secure, SameSite=Strict, host-only to app.; the __Host- prefix makes the browser refuse any Domain widening. Only its SHA-256 is stored, and the token is never in a response body.
  • Two doors, one credential each. app.<apex>/api/* is for browsers and accepts only the session cookie; api.<apex>/* is for everything else and accepts only Authorization: Bearer. The edge stamps the door (X-CS-Door, overwriting anything a client sent); the API port is reachable only from the edge and the operator's devices. "The client picks the transport" becomes "the door picks it", which removes the question of which to use.
  • A cookie request that changes state, or opens a WebSocket, must carry the app's Origin, and one that changes state also this session's CSRF token in x-cs-csrf (api/src/csrf.ts; a WebSocket cannot send headers, so it relies on the Origin). Strict rather than Lax because the apex and every subdomain are one site, so every link the platform itself makes still carries the session; the cost is that arriving from a link on another site shows the first page signed out. The Origin check is the second lock, and the only one for cross-site WebSocket hijacking, which SameSite does not cover.
  • The token door has no /api prefix. Caddy rewrites api.<apex>/<path> to the API's /api/<path>, so api.<apex>/me is the same route as the app's /api/me; one route table, two ways in.
  • Non-browser clients use personal access tokens: cs_pat_<random>, shown once, stored hashed, a name and a key (plus when it was made and last used), revoked by deleting the row. They are created, renamed and revoked only from a browser session, never with a token. (Revised 2026-10-13: tokens no longer carry a read/write scope or an expiry -- a token acts as its user and lives until revoked; 0004 and 0008 dropped the columns. The last-use time came back in 0009, written after the response rather than on the request's path.)
  • A disabled login ends its credentials. When the reconciler sets a user's password hash to "no login" (!: absent from the roster, or from the password secret), it deletes that user's sessions and tokens in the same transaction, so disabling a login is also signing them out everywhere.
  • The preference cookies are not credentials. cs-lang, cs-mode and cs-color stay SameSite=Lax and scoped to the whole apex, because every surface reads them to render its first byte (ADR-0007). Nothing here relaxes the session cookie to match them.
  • JWTs return with MCP, as short-lived OAuth 2.1 access tokens audience-bound to mcp. (the MCP specification requires OAuth for remote servers). Recorded here, deferred; until then mcp. can accept a personal access token. Rate limiting and login lockout, passkeys and an audit log are also deferred.

The session keypair this ADR called for is gone with it: nothing signs anything today.