Web Development Bug Report

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.

Hannah Okafor Hannah Okafor @hannahokafor · August 27, 2025 · 7 min read · 25 Views
Banner: logged in on app subdomain but the API subdomain saw no session, cookie Domain rules

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.com and api.example.com are the same site (same registrable domain), so SameSite=Lax does 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-Origin and Access-Control-Allow-Credentials headers.

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' (or withCredentials = true);
  • the response must include Access-Control-Allow-Origin with 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 Origin header 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 Domain only 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

Add a label

#cors #cookies #samesite #session #web-security
Written by
Hannah Okafor @hannahokafor

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.