Ch. 20 · Networking

HTTP Cookies, Scope and Browser Delivery

How Domain, Path, Secure, SameSite and expiry decide which requests carry a cookie, with curl's cookie jar showing each rule in action.

~6 min readintermediateupdated Oct 11, 2026

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=dark
Text

The 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');
JavaScript

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/orders
Terminal
logged 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=dark
Text

Each line shows a rule:

  • Path matches whole segments. session has Path=/api, so it went to /api/orders but not /account, and not /apiary either: /api matches /api and anything under /api/, not every string that starts with those letters.
  • No Domain attribute means host-only. session was set by app.shop.test without Domain, so api.shop.test never receives it, even under /api.
  • A Domain attribute widens scope. theme has Domain=shop.test, so it goes to app.shop.test, api.shop.test and any other subdomain. People often add Domain thinking it restricts a cookie. It does the opposite.
  • Secure cookies need HTTPS. cart never appears. It wasn’t sent, and it wasn’t even stored: modern curl, like browsers, refuses a Secure cookie 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	abc123
Text

theme 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=dark
Text

A 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 a SameSite attribute 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.

Putting it together:

Set-Cookie: __Host-session=9f2c...; Path=/; Secure; HttpOnly; SameSite=Lax
Text
  • __Host- is a prefix browsers enforce: the cookie must be Secure, have Path=/ and have no Domain. That locks it to one host and stops a compromised sibling subdomain from setting or overwriting it.
  • Secure keeps it off plain HTTP.
  • HttpOnly stops 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=Lax blocks 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.

More in Networking

read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
read ✓Networking · mid

CORS and Browser Response Access

CORS decides whether a page may read a cross-origin response, not whether it is sent. A browser test shows a blocked POST still saved.

~6 min readread →
esc