A single-page app cannot find its session cookie in `document.cookie` after login, yet its requests are authenticated. Why is that cookie invisible to script, and how should the app decide whether the user is signed in?
answer
- set by the server, not by script
- sent on requests, absent from the API
- not readable, not writable, not deletable
- ask the server instead of the jar
- 401 is the logout signal
basics
~20 sThe server marked the cookie HttpOnly, so the browser withholds it from document.cookie while still attaching it to matching requests. The app should learn its signed-in state from an API call such as a session endpoint, never by looking for the cookie.
solid answer
~50 s`HttpOnly` is a flag the server puts on a cookie via `Set-Cookie`, and it tells the browser to keep that cookie out of every scripting API — `document.cookie` and the promise-based `cookieStore` alike. The cookie still exists and the browser still attaches it to matching requests, which is why the network layer is authenticated while script sees nothing. Script cannot read it, overwrite it, or delete it. So "is there a session cookie?" is simply not a question the frontend can ask. The right pattern is to treat the server as the source of truth: on boot, call something like `GET /api/me` with credentials included, and drive routing off that response, treating a `401` as signed out. If you want a cheap client-side hint, have the server set a *second*, non-HttpOnly cookie carrying only non-sensitive display data — but the app must still handle a `401` on any request, because that hint can be stale or forged.
code
javascript · 15 lines// The session cookie is HttpOnly: this never finds it.
console.log(document.cookie.includes('sid=')); // false
// Ask the server instead; the browser attaches the cookie for you.
async function loadSession() {
const res = await fetch('/api/me', { credentials: 'same-origin' });
if (res.status === 401) return null; // signed out
if (!res.ok) throw new Error('session check failed');
return res.json(); // signed in
}
// Logout is a request, not a cookie deletion — script cannot clear it.
async function logout() {
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
}go deeper
Recall that a cookie marked HttpOnly is sent with requests but never appears in document.cookie, so an empty read does not mean the user is signed out.
Explain the read/write/delete asymmetry precisely and describe the correct pattern: call a session endpoint on boot and drive UI state from its response rather than from the cookie jar.
Show the production judgment — a central 401 handler that flips app state mid-session, credentials handling on cross-origin fetch, and refusing the localStorage-token trade when someone proposes it for convenience.
Own the boundary decision: which credential lives where, what the frontend is allowed to treat as authoritative, and why every client-side signal about identity is a rendering hint rather than an authorization input.
## What HttpOnly does to the scripting surface `HttpOnly` is set by the server when it issues the cookie: ```http Set-Cookie: sid=abc123; HttpOnly; Secure; Path=/; SameSite=Lax ``` From that moment, the browser treats the cookie as invisible to the document's scripts. Concretely, three things are true at once: 1. The cookie **is not** in the string returned by `document.cookie`, and is not returned by the promise-based `cookieStore.get()`/`getAll()` either. 2. Script **cannot overwrite or delete it** — a write to `document.cookie` using the same name creates or modifies a *different*, non-HttpOnly cookie rather than touching this one, or is rejected outright. 3. The cookie **is still sent** on every request whose URL matches its scope. That is the asymmetry the question is built on: the network layer is authenticated, the scripting layer is blind. Script also gets no signal when the cookie changes, expires, or is cleared. There is no event, and nothing to poll. ## Why the flag exists at all The browser's rule is a containment measure. If an attacker gets script running in your origin, everything the DOM can read is theirs — including the entire cookie jar. Marking the session cookie `HttpOnly` means that script, hostile or not, has no API that returns the credential, so it cannot be exfiltrated by reading it. It does *not* stop that script from **using** the session: the browser still attaches the cookie to same-origin `fetch` calls the attacker makes, so the flag limits credential theft, not on-page abuse. Saying "HttpOnly prevents session hijacking" is the classic overstatement to avoid. ## Fetch must be told to send credentials A subtlety that bites SPAs: `fetch()` does not attach cookies to **cross-origin** requests unless you ask. ```js const res = await fetch('https://api.example.com/me', { credentials: 'include', // required cross-origin; same-origin is the default }); ``` Same-origin requests send cookies under the default `credentials: 'same-origin'`. Cross-origin ones need `credentials: 'include'`, and the server must reply with the matching CORS headers or the browser discards the response. Teams often mistake a missing `credentials` option for a broken cookie. ## Determining auth state correctly Since the frontend cannot inspect the credential, the state must come from the server: ```js async function loadSession() { const res = await fetch('/api/me', { credentials: 'same-origin' }); if (res.status === 401) return null; // signed out if (!res.ok) throw new Error('session check failed'); return res.json(); // signed in; profile in the body } ``` Three design rules follow: - **Boot from the server.** Resolve the session once at startup, hold the result in application state, and route from that. - **Treat every 401 as a state change.** The cookie can expire mid-session with no notification, so a central response handler should flip the app to signed-out on any `401` rather than trusting the boot-time answer forever. - **Any client-side hint is advisory.** A common pattern is a companion, script-readable cookie (`Set-Cookie: display_name=Ada; Path=/`) holding only non-sensitive data, so the shell can render a signed-in header without waiting for a round trip. It is a rendering optimisation, never an authorisation decision — the user can edit it in devtools in five seconds. ## The anti-patterns this rules out - **Moving the token to `localStorage` "so the app can read it".** That trades an unreadable credential for a readable one and hands any injected script exactly what `HttpOnly` was withholding. - **Duplicating the session id into a readable cookie for convenience.** Same trade, dressed differently. - **Clearing the session by deleting the cookie in JS on logout.** Script cannot delete an HttpOnly cookie. Logout has to be a request the server answers with an expiring `Set-Cookie` for that name. - **Decoding the token client-side to check expiry.** Not possible for a cookie you cannot read; and even when a readable token exists, its expiry claim is not the server's opinion. ## The summary an interviewer wants "The cookie is HttpOnly, so it exists and is sent but no scripting API returns it. The frontend cannot know the session state by inspection; it asks the server, caches the answer in app state, and treats a 401 on any subsequent call as being logged out."
- Does HttpOnly stop an injected script from acting as the logged-in user?No, and claiming it does is the common overstatement. The flag stops script from *reading* the credential, so it cannot be copied to an attacker's server. The browser still attaches the cookie to same-origin requests, so injected script can call your API as the user for as long as the page is open. HttpOnly limits credential theft and persistence, not on-page abuse.
- The team wants a `localStorage` token instead, so the app can check expiry itself. What do you say?That it converts an unreadable credential into a readable one for a convenience the server can provide anyway. Any script running in the origin can read `localStorage`, and unlike a cookie the token then has to be attached by hand on every request. If the app needs expiry awareness, have the session endpoint return it, or let a 401 drive the transition.
- Logout runs `document.cookie = 'sid=; max-age=0'` and the user is still authenticated. Why?Because script cannot delete an HttpOnly cookie. The write either does nothing or creates a separate non-HttpOnly cookie of the same name, while the real one is untouched and still sent. Logout must be a server call that responds with `Set-Cookie: sid=; Max-Age=0` for the same name, path and domain — and ideally invalidates the session server-side too.
- How should the app render a signed-in header before the session request resolves?Have the server set a companion, script-readable cookie with only non-sensitive display data — a name or an avatar id — and render optimistically from it. Treat it strictly as a rendering hint: the user can edit it, so no route guard or permission check may consult it, and the app must still reconcile with the session response and handle a 401.
The session cookie is a coat-check ticket the browser carries in its own pocket and presents at the counter on every request; your script never gets to hold the ticket, so it has to ask the counter whether the coat is still there.
saying these in an interview costs you the question
- Thinks the cookie is missing because the login request failed
- Believes HttpOnly prevents session hijacking outright
- Proposes moving the token to localStorage so JavaScript can read it
- Tries to log the user out by deleting the cookie in script
- Assumes fetch sends cookies cross-origin without credentials: 'include'