Logged in on app.example.com but the API on api.example.com saw no session: cookie Domain rules
After splitting our frontend and API onto separate subdomains, users logged in and were then treated as anonymous by every API call. The session cookie was host-only and fetch did not send credentials. How cookie scoping works across subdomains, the CORS settings that go with it, and the trade-offs.
We split a monolith into a single-page frontend at app.example.com and an API at api.example.com.
Login is handled by the API: the frontend posts credentials to https://api.example.com/auth/login,
which sets a session cookie. In testing on a developer's machine, everything worked through a local
proxy that served both on the same host. In staging, users logged in, saw the dashboard flash, and were
redirected back to the login page. Every API call after login returned 401.
Following the cookie
The login response looked right:
HTTP/2 200
set-cookie: sid=s%3A9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
The next request, GET https://api.example.com/me, had no Cookie header in the network panel. In the
browser's storage view, the sid cookie existed, with domain api.example.com. So the cookie was stored
for the API host, and the request was to the API host, yet it was not sent.
Two separate problems
Looking at how the frontend made requests:
const res = await fetch('https://api.example.com/me');
No credentials option. For cross-origin requests, fetch defaults to credentials: 'same-origin',
which does not send cookies to another origin. app.example.com and api.example.com are different
origins. That explained the missing header for API calls.
The second problem appeared once we fixed the first: server-rendered pages on app.example.com (for SEO
landing pages) also needed to know whether the user was logged in, and the cookie, scoped to
api.example.com, was never sent to the app host at all.
What we ruled out
- SameSite blocking.
app.example.comandapi.example.comare the same site (same registrable domain), soSameSite=Laxdoes not block the cookie on these requests. - Third-party cookie blocking. Same reason: browsers' third-party cookie restrictions apply to cross-site requests, not same-site subdomains.
- CORS headers. The API returned the right
Access-Control-Allow-OriginandAccess-Control-Allow-Credentialsheaders.
Root cause
The session cookie was set without a Domain attribute, which makes it host-only: it is stored for and
sent only to api.example.com. Requests from the frontend to the API were cross-origin and used the
default credentials mode, so the browser did not attach the cookie. And the app host could never receive
the cookie because of its host-only scope.
Origin versus site
| Term | Defined by | app.example.com vs api.example.com |
|---|---|---|
| Origin | Scheme + host + port | Different origins |
| Site | Scheme + registrable domain (eTLD+1) | Same site |
CORS and fetch credentials care about origin. SameSite cookie rules and third-party cookie
blocking care about site. Mixing up the two explains most cookie confusion.
Cookie Domain attribute
| Set-Cookie | Sent to |
|---|---|
no Domain |
Only the exact host that set it |
Domain=example.com |
example.com and every subdomain |
Domain=api.example.com |
api.example.com and its subdomains |
A host can only set a cookie for itself or a parent domain, never for a sibling or an unrelated domain.
So api.example.com can set Domain=example.com, which covers app.example.com.
Credentials in cross-origin requests
For the browser to send cookies on a cross-origin fetch or XHR and expose the response:
- the request must use
credentials: 'include'(orwithCredentials = true); - the response must include
Access-Control-Allow-Originwith the exact origin, not*; - the response must include
Access-Control-Allow-Credentials: true.
Environment
| Component | Value |
|---|---|
| Frontend | React SPA on app.example.com, plus server-rendered landing pages |
| API | Node.js on api.example.com, cookie sessions |
| Session cookie | sid, HttpOnly, Secure, SameSite=Lax, no Domain |
| Local development | Single host behind a dev proxy, so the bug did not appear |
Scope the cookie to the parent domain
res.cookie('sid', sessionId, {
domain: 'example.com',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 8 * 60 * 60 * 1000,
});
Now the browser sends sid to both app.example.com and api.example.com.
Send credentials from the frontend
const api = (path, init = {}) =>
fetch(`https://api.example.com${path}`, { ...init, credentials: 'include' });
And the API's CORS configuration allows exactly the frontend origin with credentials:
app.use(cors({
origin: ['https://app.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));
Protect state-changing requests
Sharing the cookie across subdomains and allowing credentialed CORS makes CSRF protection more important.
SameSite=Lax blocks cookies on cross-site POSTs, but every subdomain of example.com is same-site, so a
compromised or user-content subdomain could send authenticated requests. We added a CSRF token, issued on
login and required in a header on every state-changing request, and the API rejects requests whose
Origin header is not the frontend origin.
Consider the alternative: same origin
The cleanest option, which we adopted a quarter later, is serving the API under the app's origin, at
https://app.example.com/api/, through the CDN or a reverse proxy. Then the cookie can be host-only, and
even use the __Host- prefix:
set-cookie: __Host-sid=...; Path=/; Secure; HttpOnly; SameSite=Lax
A __Host- cookie must be Secure, must have Path=/ and must not have a Domain attribute, so it
can never be shared with or overwritten by another subdomain. It is the strongest scoping available.
Alternatives and trade-offs
| Option | Pros | Cons |
|---|---|---|
Domain=example.com |
Simple, works for many subdomains | Every subdomain receives the session cookie |
| API on the same origin | Host-only __Host- cookie, no CORS |
Needs a proxy or CDN routing |
Tokens in Authorization header |
No cookie scoping issues | Token storage in JS is exposed to XSS |
How to test the fix
curl -s -c jar.txt -X POST https://api.staging.example.com/auth/login \
-H 'Origin: https://app.staging.example.com' -d '{"email":"qa@example.com","password":"..."}' \
-H 'content-type: application/json' -o /dev/null
grep sid jar.txt # domain column shows .staging.example.com
A Playwright test logs in through the UI and asserts that /me returns the user and that a
server-rendered page on the app host shows the logged-in state.
Confirming in production
After the release, logins persisted, /me returned 200, and server-rendered pages recognised the
session. The 401 rate after login dropped to the baseline of expired sessions.
Regression guard
- An end-to-end test runs against staging with the real domain layout, not a local proxy.
- The cookie options live in one function shared by login and session refresh.
- A security test checks that requests with a foreign
Originheader to state-changing endpoints are rejected.
Security implications
Domain=example.com sends the session cookie to every subdomain, including marketing sites, status
pages and anything hosting user content. If any of those is compromised or serves attacker-controlled
scripts, the session is at risk. We inventoried subdomains, moved user content to a separate registrable
domain, and later moved to a same-origin API with a __Host- cookie, which removes the exposure entirely.
Prevention checklist
- Know whether you need origin-level or site-level sharing before choosing cookie scope.
- Set
Domainonly when cookies must be shared across subdomains, and know every subdomain. - Use
credentials: 'include'with exact-origin CORS for cross-origin APIs. - Add CSRF protection when cookies are shared and credentialed CORS is enabled.
- Prefer a same-origin API with
__Host-cookies when possible.
References
- MDN, Set-Cookie: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
- MDN, Using HTTP cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
- MDN, CORS requests with credentials: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#requests_with_credentials
- web.dev, Understanding same-site and same-origin: https://web.dev/articles/same-site-same-origin
- OWASP CSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/cookie-not-shared-across-subdomains-domain" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.