Cookies are the oldest state mechanism on the web, and still the usual home for session IDs. Most cookie bugs aren’t about setting a cookie, they’re about it not being sent when you expected (or being sent when you didn’t). The rules that decide which requests carry which cookies are precise, and interviewers ask about them in two forms: “why isn’t my cookie reaching the API?” and “how would you configure a session cookie securely?”
This note works through the rules with a local Node server and curl, whose cookie engine follows the same matching rules browsers use for domain, path, Secure and expiry.
Setting and sending are two different formats
A server sets a cookie with a Set-Cookie response header: a name, a value, and attributes that tell the browser how to scope it. The browser stores all of that. On later requests, it decides which stored cookies match the URL and sends them in a single Cookie header, with names and values only:
Set-Cookie: session=abc123; Path=/api; HttpOnly; SameSite=Lax
Cookie: session=abc123; theme=darkThe server never sees the attributes again. It can’t tell from a request whether a cookie was HttpOnly, which path it was scoped to, or when it expires. All the scoping decisions happen in the client.
Here’s the test server. /login sets three cookies with different scopes, and every other path echoes back the Cookie header it received:
import http from 'node:http';
http.createServer((req, res) => {
const host = req.headers.host.split(':')[0];
if (req.url === '/login') {
res.setHeader('set-cookie', [
'session=abc123; Path=/api; HttpOnly; SameSite=Lax', // host-only, only under /api
'theme=dark; Path=/; Domain=shop.test; Max-Age=86400', // shared with every *.shop.test host
'cart=7; Path=/; Secure', // only over HTTPS
]);
return res.end(`logged in on ${host}\n`);
}
if (req.url === '/logout') {
res.setHeader('set-cookie', 'session=; Path=/api; Max-Age=0'); // same name + path, expired
return res.end('logged out\n');
}
res.end(`${host}${req.url} received Cookie: ${req.headers.cookie ?? '(none)'}\n`);
}).listen(8094, '127.0.0.1');Watching the scope rules with curl
curl’s --resolve points two made-up hostnames, app.shop.test and api.shop.test, at the local server, and -c/-b save and send cookies through a jar file:
R="--resolve app.shop.test:8094:127.0.0.1 --resolve api.shop.test:8094:127.0.0.1"
curl -s $R -c jar.txt http://app.shop.test:8094/login
curl -s $R -b jar.txt http://app.shop.test:8094/api/orders
curl -s $R -b jar.txt http://app.shop.test:8094/account
curl -s $R -b jar.txt http://app.shop.test:8094/apiary
curl -s $R -b jar.txt http://api.shop.test:8094/api/orderslogged in on app.shop.test
app.shop.test/api/orders received Cookie: session=abc123; theme=dark
app.shop.test/account received Cookie: theme=dark
app.shop.test/apiary received Cookie: theme=dark
api.shop.test/api/orders received Cookie: theme=darkEach line shows a rule:
- Path matches whole segments.
sessionhasPath=/api, so it went to/api/ordersbut not/account, and not/apiaryeither:/apimatches/apiand anything under/api/, not every string that starts with those letters. - No Domain attribute means host-only.
sessionwas set byapp.shop.testwithoutDomain, soapi.shop.testnever receives it, even under/api. - A Domain attribute widens scope.
themehasDomain=shop.test, so it goes toapp.shop.test,api.shop.testand any other subdomain. People often addDomainthinking it restricts a cookie. It does the opposite. - Secure cookies need HTTPS.
cartnever appears. It wasn’t sent, and it wasn’t even stored: modern curl, like browsers, refuses aSecurecookie set over plain HTTP.
The jar file shows what was stored:
.shop.test TRUE / FALSE 1791822330 theme dark
#HttpOnly_app.shop.test FALSE /api FALSE 0 session abc123theme has an absolute expiry (a Unix timestamp a day out, from Max-Age=86400). session has 0: no Max-Age or Expires, so it’s a session cookie that a browser drops when the session ends. The #HttpOnly_ prefix records that scripts can’t read it.
Logging out works because the deletion matches the original cookie exactly:
logged out
app.shop.test/api/orders received Cookie: theme=darkA cookie is identified by name, domain and path together. If the logout handler had sent session=; Path=/; Max-Age=0, it would have expired a different, nonexistent cookie, and the real session would have kept working. That’s a common reason “logout doesn’t log me out”.
SameSite and cross-site requests
curl doesn’t model where a request came from, but browsers do, and that’s what SameSite controls. The key word is site, not origin. An origin is scheme, host and port. A site is the scheme plus the registrable domain: app.shop.test and api.shop.test are different origins but the same site.
- Strict: the cookie goes only on requests started from the same site. Following a link to your app from another site, such as a search result or a webmail message, arrives without it, so users look logged out on that first page.
- Lax: also sent when the user follows a link from another site to yours (a top-level GET page load), but not on cross-site POSTs, iframes, images or
fetch. Chrome treats cookies without aSameSiteattribute as Lax. - None: sent on cross-site requests as well, and must be paired with
Secure. Browsers increasingly block or partition these third-party cookies anyway.
Lax blocks the classic CSRF attack, a hidden form on an attacker’s site posting to your bank, because the cross-site POST arrives without the session. It doesn’t protect one subdomain from another, since they’re same-site.
A session cookie that holds up
Putting it together:
Set-Cookie: __Host-session=9f2c...; Path=/; Secure; HttpOnly; SameSite=Lax__Host-is a prefix browsers enforce: the cookie must beSecure, havePath=/and have noDomain. That locks it to one host and stops a compromised sibling subdomain from setting or overwriting it.Securekeeps it off plain HTTP.HttpOnlystops JavaScript reading it, so an XSS bug can’t send the session ID to an attacker. It doesn’t stop injected script from making requests that carry the cookie, so it limits XSS damage rather than preventing it.SameSite=Laxblocks cross-site POSTs carrying it.- No
Max-Age, or a short one, plus server-side expiry for the session itself.
One more misconception: Path isn’t a security boundary. Pages on the same origin can reach each other’s cookies through frames and scripts, whatever the path. Use separate hostnames for real isolation.
Interview practice
Your web app ran at app.shop.test with its API under app.shop.test/api. The API has moved to api.shop.test. Since the move, logged-in users get 401 on every API call from the web app, though the login page says they’re signed in. What’s happening, and what are your options?
Answer
The session cookie was set by app.shop.test without a Domain attribute (and possibly with Path=/api), so it’s host-only. The browser will never attach it to requests for api.shop.test. There’s also a second issue: calls from the app to the API are now cross-origin, so even a cookie that matched wouldn’t be sent by fetch unless the request uses credentials: 'include', and the API’s CORS response would need Access-Control-Allow-Credentials: true with the exact origin, not *. SameSite isn’t the problem here; both hosts are the same site.
I’d pick between three fixes. One: keep the browser talking to a single host by routing app.shop.test/api through a reverse proxy to the new API. The cookie stays host-only (even __Host- prefixed) and nothing else changes. Two: reissue the cookie with Domain=shop.test and set up credentialed CORS. That works, but every subdomain now receives the session, including any marketing or user-content host, so I’d only do it if they’re all trusted. Three: switch API calls to a short-lived bearer token. I’d usually choose the proxy, since it keeps the narrowest cookie scope and avoids CORS entirely.