Thesis: An HttpOnly cookie only hides what never returns to script. If a session endpoint reads the cookie and echoes the refresh token in the body, XSS that can call that endpoint still owns the session.
What we shipped first
The browser stopped writing tokens into sessionStorage on the happy path. Refresh lived in an HttpOnly cookie. A hard reload called GET /auth/session with credentials included. The green path restored the user.
The response body still included refresh_token. Script that could issue that fetch recovered the long-lived credential. Scoping the cookie to the apex domain widened who might send it. A legacy storage branch waited if the cookie path was ever turned off.
The working shape
Keep refresh on the server side of the cookie jar. Exchange it for short-lived access and id tokens without putting refresh in JSON. Prefer a host-only cookie. Delete the storage fallback on the production path.
# Wrong green: cookie is HttpOnly, JSON still leaks refresh
GET /auth/session → { "tokens": { "refresh_token": "…" } }
# Working shape: cookie stays opaque to JS
POST /auth/session/refresh → { "tokens": { "access_token": "…", "id_token": "…" } }
# refresh_token never leaves the HttpOnly cookie
Checklist
- Inspect the session restore response: no refresh_token field.
- Refresh server-side; return only short-lived access and id tokens to memory.
- Omit Domain= so the cookie is host-only on the API host.
- Confirm production never writes tokens to sessionStorage.
Try the lab
Curl the session, see what XSS can still steal, patch the BFF, prove refresh never returns in JSON. New here? help. Stuck? help next.
Full page: Open this lab · All labs
Related: the browser does not get the object key; Managed Login only got Privacy and Terms. How we deliver: methodology.
Engineering commentary only — not audit, legal, or certification advice.