Skip to content

Keep the session token out of page scripts - #2108

Merged
ppXD merged 1 commit into
mainfrom
fix/keep-the-session-token-out-of-page-scripts
Oct 12, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/keep-the-session-token-out-of-page-scripts

Conversation

@ppXD

@ppXD ppXD commented Oct 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • The browser session moves out of localStorage into an HttpOnly cookie. Before this, any script running in the SPA could read the 24-hour JWT (codespace.jwt) and send it anywhere. The allowlist sanitizer and CSP (Harden frontend rendering of untrusted content #2098) narrow how such a script gets in; this removes what it could take. API clients, the CLI, MCP and tests keep the Authorization: Bearer header and see no change.
  • A response never puts a session token in its body when the request was authenticated by the cookie. A page script can send the cookie and the CSRF header itself. Without this rule it could accept an invitation its author sent and read a fresh, portable token for the victim.
  • Moving existing browsers over needs no action from users. A browser holding a pre-cookie token exchanges it once for the cookie, then deletes it. A rolling deploy of the API does not sign users out, as long as the API finishes rolling out before the SPA (see Deploy order).

Behaviour changes

API

  • New session cookie. POST /api/auth/sign-in, POST /api/auth/change-password and POST /api/invitations/{token}/accept now also send Set-Cookie: codespace_session=<jwt> with these attributes (backend/src/CodeSpace.Core/Services/Auth/SessionCookie.cs):
    • HttpOnly.
    • SameSite=Strict. Lax would add only top-level GET navigations from other sites, and nothing needs one: the SPA reaches /api by fetch from its own site; the OAuth callback (CredentialOAuthController.Callback) is anonymous and carries its own Lax nonce cookie; and the CSP's form-action 'none' rules out forms.
    • Path=/api.
    • Max-Age equal to the token's remaining lifetime.
    • Secure exactly when Request.IsHttps, the same rule as the OAuth nonce cookie.
  • Where the token is read. The bearer handler reads the Authorization header first. It reads the cookie only when the request has no such header, so a dead header is never rescued by a live cookie. The security-stamp check (RejectDeadSessionsAsync) runs whichever way the token arrived, so a password change, a reset or a deactivation ends cookie sessions too.
  • CSRF guard. A POST, PUT, PATCH, DELETE or other unsafe method authenticated by the cookie must carry X-CodeSpace-Csrf. Without it the cookie is ignored and the request is anonymous. Bearer requests need no such header.
  • CORS. AllowCredentials stays. Its comment now says why: a cross-origin SPA (VITE_API_URL) needs it to send and keep the cookie.
  • Token left out of the body. token is omitted from the body of those three responses when the request was authenticated by the cookie. The cookie is still set, and bearer callers still get token. In the C# records, SignInResponse.Token and ChangePasswordResponse.Token are now string? with JsonIgnore(WhenWritingNull).
  • New /api/auth/session endpoints:
    • GET answers "am I signed in" with a 204 when the request carries a live credential.
    • POST with a bearer header sets the cookie to that same token. It never extends the token and never puts it in the body.
    • DELETE clears the cookie with the attributes it was set with. Sessions in other browsers stay live; changing the password is what ends every session.

SPA

  • No token in page state. No session token is written to localStorage once the API has confirmed the cookie, which takes a POST then a GET to /api/auth/session. The only thing stored is the non-secret flag codespace.session=cookie, which drives isAuthenticated().
  • When the bearer token is kept. The SPA keeps the bearer token in localStorage only while the API has not confirmed a cookie. That happens with a pre-cookie API pod, or with an API on another site, where SameSite=Strict withholds the cookie (see frontend/.env.example).
  • What every request sends. Every request uses credentials: "include" and sends X-CodeSpace-Csrf: 1. "include" rather than the default "same-origin", because the deployed SPA may call the API at VITE_API_URL, another origin. Artifact downloads and the run-ledger stream (SSE over fetch) go through the same path.
  • Token never kept in page state. Sign-in, invitation accept and password change settle the session inside the mutation (adoptSessionFrom) and keep the response without token. They also set gcTime: 0, so react-query drops the result and the password variables once the page moves on, instead of keeping them for 5 minutes where the DOM can reach them.
  • 401 handling. On a 401 the SPA still clears local state and redirects to /signin, with one exception: a browser on the cookie first re-asks GET /api/auth/session. A 204 means the 401 came from a pod that cannot read the cookie. The session is kept and the 401 surfaces as an ordinary error, which queries retry.
  • Sign-out. Sign-out calls DELETE /api/auth/session, then clears local state even if the API cannot be reached.

Deploy order

  • Roll every API pod before the SPA image. A pre-cookie pod reads only the Authorization header. If a browser that has moved onto the cookie meets one, and the follow-up probe meets one too, that browser is signed out.
  • Rolling the API back after the SPA moved browsers onto the cookie: each such browser signs in once more and then stays on the bearer token. On an old pod, POST /api/auth/session answers 404, so the SPA keeps the token.
  • No migrations. nginx.conf changes only a comment; no header or path changes.

Test plan

  • Unit: SessionCookieTests. Wire names are pinned. Issue and Clear set the expected attributes over http and https. Header-first precedence. Safe vs unsafe methods. Authenticated is true only for a principal the cookie established. Full unit suite passes (13400).
  • Integration (real Postgres): SessionCookieAuthenticationTests. Cookie-only authentication. Stamp rotation kills the cookie. A dead header is not rescued by a cookie. A live header decides the identity. Cookie writes are refused without the CSRF header. Bearer writes need no CSRF header. Only a principal from the cookie counts as carried by it.
  • E2E over WebApplicationFactory (SessionCookieE2ETests):
    • Cookie flags per scheme.
    • The cookie alone is a session.
    • CSRF refusal for POST, PUT, PATCH and DELETE.
    • A bearer client is unaffected by a stray cookie.
    • Sign-out clears the cookie.
    • A password change replaces the cookie and kills the old one.
    • Deactivation ends the cookie's session.
    • Exchange keeps the same token; a dead token cannot be exchanged.
    • The dev cross-origin preflight grants the CSRF header with credentials.
    • Sign-in, change-password and accept (invitation created through the product endpoints): no token in the body when the cookie carried the request, and the token is still returned to bearer callers.
    • All Surface=Http E2E pass (305).
  • Frontend vitest:
    • No token written to localStorage.
    • One-time migration exchange, and fallback to the bearer token against pre-cookie pods.
    • On the cookie, a 401 re-probes and redirects to /signin only when the probe also fails.
    • A two-pod rolling-deploy simulation.
    • The sign-in, invite and change-password routes never hold the token in any mutation-cache state, and leave no mutation behind after navigation.
    • SSE and download credentials.
    • Full suite passes (2493), plus tsc --noEmit -p tsconfig.app.json, pnpm lint (0 errors) and pnpm build.

The SPA kept its 24-hour JWT in localStorage, so any script that ran in
the page could read it and send it anywhere. The allowlist sanitizer and
CSP narrow how such a script gets in; this removes what it could take.

Sign-in, invitation accept and password change now set the token in an
HttpOnly, SameSite=Strict cookie scoped to /api, Secure exactly when the
request is HTTPS and living as long as the token. The bearer handler
reads it only when no Authorization header is present, so API clients,
the CLI, MCP and tests are untouched. A write that rides the cookie must
carry X-CodeSpace-Csrf, which no form or preflight-free request from
another site can set. The stamp check runs whichever way the token
arrived, so every way a session ends still ends the cookie's.

A script in the page can send the cookie and that header too, so a
response to a request the cookie carried never puts the minted token in
its body. Otherwise accepting an invitation the script's author sent
would mint the victim a fresh, portable token. Bearer callers still get
it. The SPA settles a session inside the mutation that received it and
keeps the response without the token, and drops those mutations with
the page: react-query keeps results and variables (the passwords)
reachable from the DOM for minutes.

/api/auth/session answers "am I signed in" (GET), trades a stored token
for the cookie without extending it (POST), and signs the browser out
(DELETE). The SPA moves onto the cookie only after the API confirms the
browser sends it back; until then, including against an API on another
site or pods from before this change, it keeps the bearer token it
already had. Once on the cookie, a 401 is checked against the API
before it signs anyone out, because a pre-cookie pod reads only the
header. That check can reach such a pod too, so the API must finish
rolling out before the SPA does; rolling the API back later signs
cookie users out once.
@ppXD
ppXD merged commit 42f8eb6 into main Oct 12, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant