jguillaumesio
cookieslocalhostnginxhttpsdevopsfullstackdx

Sharing cookies across subdomains locally

A prod-identical local setup where cookies are shared across subdomains over real HTTPS, with zero app code changes. The trick lives in /etc/hosts + nginx.

Subdomain cookie sharing: production vs. local setup

1. The problem: local env that lies about cookies

In production, a multi-service app usually lives on sibling subdomains:

  • app.example.com, the frontend
  • api.example.com, the backend
  • auth.example.com, the auth service

They share session state through a domain cookie scoped to the parent: Domain=.example.com. The browser then attaches that cookie to every subdomain automatically. Clean, standard, done.

Locally, the typical setup is a pile of ports:

  • localhost:3000, frontend
  • localhost:4000, api

This looks fine, but it quietly diverges from prod in ways that cost you hours:

  • You can’t reproduce the parent-domain cookie scope, so you end up with a different auth code path for local vs prod (manually copying tokens, disabling Secure, special-casing Domain).
  • SameSite / Secure behavior can’t be tested faithfully because you’re on plain HTTP.
  • “Works locally, breaks in staging” becomes a recurring genre of bug.

The goal of this article: a local environment that is byte-for-byte identical in cookie behavior to prod, without a single conditional in your app.

Quick note on ports: cookies are not isolated by port. A host-only cookie set by localhost:3000 is sent to localhost:4000, same host. That’s actually part of the problem: bare localhost either over-shares (everything is the same host) or can’t share at all (you can’t create a parent scope). Neither matches a real subdomain topology.


2. Why you can’t just use .localhost

The instinct is: “I’ll use app.localhost and api.localhost and scope the cookie to .localhost.” It doesn’t work, and the reason is precise, not folklore.

Host-only vs domain cookies

  • No Domain attributehost-only cookie. Sent only to the exact host that set it.
  • With Domain attributedomain cookie. Sent to that domain and all its subdomains.

To share across app.* and api.*, you need a domain cookie scoped to their common parent.

The Public Suffix rule (the real reason)

Per RFC 6265 and the Public Suffix List algorithm, a browser rejects a Domain cookie whose value is a public suffix (an “effective TLD”). You’re only allowed to scope a cookie to a registrable domain (eTLD + 1) or below.

Now run the algorithm on each candidate:

HostEffective TLDRegistrable domain (eTLD+1)Can you set Domain=<parent>?
app.localhostlocalhostapp.localhostDomain=localhost is a public suffix → rejected
app.localhost.comcomlocalhost.comDomain=localhost.com is registrable → accepted

That’s the whole trick in one table: localhost is treated as a single-label TLD, so there’s no parent scope to attach a shared cookie to. Add a real TLD (.com) and a legitimate parent (localhost.com) appears.

Aside, the reserved .localhost TLD: RFC 6761 reserves .localhost to always resolve to loopback, and some browsers special-case it. Handy for resolution, useless for cookie sharing, it’s still a single label, so the rejection above still applies.

The legacy “needs two dots” lore: old Netscape-era cookie code enforced a literal dot-count rule. Modern browsers replaced it with the Public Suffix check. The dotted-domain requirement is a consequence of the PSL rule, not the rule itself.


3. The fix: /etc/hosts + a dotted domain

Pick a dotted domain whose parent is registrable. localhost.com works well.

localhost.com is a real registered domain owned by someone else, but that’s irrelevant here, because /etc/hosts overrides DNS and forces it to 127.0.0.1 on your machine. If you’d rather avoid any real domain entirely, use a reserved test TLD like *.myapp.test (.test is reserved by RFC 6761 and will never be a real site). The mechanics are identical.

Edit /etc/hosts

/etc/hosts has no wildcard support, so list each subdomain explicitly:

# /etc/hosts  (Linux/macOS) — C:\Windows\System32\drivers\etc\hosts on Windows
127.0.0.1   localhost.com
127.0.0.1   app.localhost.com
127.0.0.1   api.localhost.com
127.0.0.1   auth.localhost.com

Flush the DNS cache

# macOS
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
# Linux (systemd-resolved)
sudo resolvectl flush-caches
# Windows (PowerShell as admin)
ipconfig /flushdns

Verify

ping app.localhost.com        # should answer from 127.0.0.1
curl -I http://app.localhost.com:3000

Want true wildcard resolution (so any *.localhost.com resolves without editing the file each time)? That’s the dnsmasq upgrade, covered in the appendix at the bottom. For most projects, four explicit lines is simpler and enough.


4. Now cookies just work

With resolution in place, set a domain cookie scoped to the parent. This is the only “cookie-aware” line in the whole setup, and it’s the same line you’d use in prod.

Server (Express example)

res.cookie("session", token, {
  domain: ".localhost.com", // ← parent scope; in prod this is ".example.com"
  httpOnly: true,
  sameSite: "lax",
  secure: true,             // requires HTTPS — see section 5
  path: "/",
});

The raw header the browser receives:

Set-Cookie: session=...; Domain=.localhost.com; Path=/; HttpOnly; SameSite=Lax; Secure

Result

A request to api.localhost.com now automatically carries the cookie set by app.localhost.com, exactly as it would across api.example.com / app.example.com in prod. The browser does the matching; your app does nothing special.

Driving toward parity: the Domain value should come from config, not a conditional

The clean pattern is a single env-driven value, identical shape in every environment:

// config
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN; // ".localhost.com" locally, ".example.com" in prod

No if (isLocal) branch anywhere. That’s the win: prod parity with zero special-casing.


5. Going further: real HTTPS locally with nginx

Notice the Secure flag above. Secure cookies are only sent over HTTPS, and SameSite=None (needed for genuine cross-site flows) requires Secure. To test cookie behavior truthfully, your local env should speak HTTPS too. nginx + a wildcard cert gets you there, and now https://app.localhost.com is genuinely close to prod.

Generate a wildcard certificate

The painless route is mkcert, which installs a trusted local CA so the browser shows no warnings:

mkcert -install
mkcert "*.localhost.com" localhost.com
# → produces _wildcard.localhost.com.pem  and  _wildcard.localhost.com-key.pem
OpenSSL alternative (no extra tooling, but you must trust the cert manually)
openssl req -x509 -nodes -newkey rsa:2048 \
  -keyout localhost.com-key.pem \
  -out localhost.com.pem \
  -days 825 \
  -subj "/CN=*.localhost.com" \
  -addext "subjectAltName=DNS:*.localhost.com,DNS:localhost.com"

Then add the resulting cert to your OS/browser trust store.

nginx as a TLS-terminating reverse proxy

nginx listens on 443, terminates TLS, and proxies each subdomain to the right local port:

# /etc/nginx/conf.d/localhost.com.conf
upstream frontend { server 127.0.0.1:3000; }
upstream backend  { server 127.0.0.1:4000; }

server {
    listen 443 ssl;
    server_name app.localhost.com;

    ssl_certificate     /path/to/_wildcard.localhost.com.pem;
    ssl_certificate_key /path/to/_wildcard.localhost.com-key.pem;

    location / {
        proxy_pass http://frontend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP         $remote_addr;
    }
}

server {
    listen 443 ssl;
    server_name api.localhost.com;

    ssl_certificate     /path/to/_wildcard.localhost.com.pem;
    ssl_certificate_key /path/to/_wildcard.localhost.com-key.pem;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP         $remote_addr;
    }
}
sudo nginx -t && sudo nginx -s reload

Now https://app.localhost.com and https://api.localhost.com are live, trusted, sharing a Secure cookie on .localhost.com. That’s preprod parity on your laptop.

Important for Express behind a proxy: set app.set("trust proxy", 1) so Secure cookies and req.protocol respect the X-Forwarded-Proto header nginx sends.


6. The full picture

ProductionLocal (this setup)
Frontend hostapp.example.comapp.localhost.com
API hostapi.example.comapi.localhost.com
Cookie scope.example.com.localhost.com
TransportHTTPSHTTPS (nginx + mkcert)
App code,identical, no branches

The only things that differ between environments are values in config (COOKIE_DOMAIN, base URLs), never logic.


7. Gotchas & troubleshooting

  • Cookie not being set? Check the leading-dot Domain, confirm you’re on HTTPS if Secure is set, and confirm the request host actually matches the cookie’s domain.
  • CORS with credentials: cross-subdomain XHR/fetch needs withCredentials: true (client), Access-Control-Allow-Credentials: true (server), and a specific Access-Control-Allow-Origin, the * wildcard is forbidden when credentials are involved.
  • SameSite: Lax covers most same-site subdomain flows. Use None only for genuine cross-site contexts, and remember it mandates Secure (hence section 5).
  • DNS cache: if a new subdomain won’t resolve, flush the cache (section 3) or restart the browser.
  • Express behind nginx: without trust proxy, Secure cookies silently won’t be sent.
  • Stale certs: re-run mkcert -install if the browser distrusts the CA after an OS update.

Bonus aside: making it work on mobile

Native apps (and some webviews) don’t persist and replay cookies the way a browser does, so the elegant browser story above falls apart on mobile. A compact workaround that keeps server behavior identical:

  1. Parse Set-Cookie with the same library Express uses, the cookie npm package, so parsing is byte-identical to the backend’s expectations.
  2. Persist everything (e.g. secure storage / AsyncStorage).
  3. Re-inject on every outgoing request via an axios request interceptor, axios.interceptors.request.use().
import cookie from "cookie";
import axios from "axios";

// Capture: parse and store Set-Cookie from responses
axios.interceptors.response.use((res) => {
  const setCookie = res.headers["set-cookie"];
  if (setCookie) {
    const jar = setCookie.map((c) => cookie.parse(c));
    storage.saveCookies(jar); // your persistence layer
  }
  return res;
});

// Replay: inject stored cookies on every request
axios.interceptors.request.use((config) => {
  const stored = storage.loadCookies(); // { session: "...", ... }
  config.headers.Cookie = Object.entries(stored)
    .map(([k, v]) => cookie.serialize(k, v))
    .join("; ");
  return config;
});

Using the same parser as the server avoids the subtle “it parsed differently on mobile” bugs. Keep this layer thin, it’s a bridge for environments without a real cookie jar, not a replacement for one.


Conclusion

Production-grade cookie behavior locally comes down to three moves: dotted subdomains in /etc/hosts, a parent-scoped Domain, and nginx for real HTTPS. The payoff is a local environment that stops lying to you, same hosts shape, same cookie scope, same transport, same code. The class of “only breaks in staging” auth bugs largely disappears.


Appendix: wildcard resolution with dnsmasq

If you add subdomains often and don’t want to keep editing /etc/hosts, dnsmasq resolves an entire wildcard to loopback:

# dnsmasq.conf
address=/localhost.com/127.0.0.1

Combined with the wildcard cert from section 5, every *.localhost.com you invent just works, no per-subdomain edits, no new certs. Setup differs by OS (resolver config on macOS via /etc/resolver/, NetworkManager/systemd-resolved on Linux), so treat this as the “level up” once the explicit-entry version is paying off.