Cross-Domain Authentication Across Regional Subdomains

Sharing a login across app.example.com and uk.example.com looks like a cookie setting. The details are where the security bugs live.

Hafeez BaigHafeez BaigMar 24, 202610 min read

Introduction

A product grows into more than one region, and a question arrives with it. A user signs in at app.example.com. Should they still be signed in at uk.example.com? The business answer is usually yes. The engineering answer is usually "set the cookie Domain to the parent and move on". That answer is right about half the time, and the half where it is wrong produces security bugs.

The problem turns on one distinction. Sometimes your regional sites are subdomains of a single registrable domain, and sometimes they are genuinely separate domains. Cookies can be shared in the first case, and cannot be shared in the second under any configuration. Everything else follows from knowing which you are in.

1. The Distinction That Decides Everything

A registrable domain is the piece of a hostname a company actually registers with a registrar. For uk.example.com, it is example.com. For app.example.co.uk, it is example.co.uk. Browsers work this out using the public suffix list, which I will come back to.

  • Same registrable domain, different subdomains. uk.example.com, ae.example.com, and app.example.com all sit under example.com. A cookie can be scoped to example.com and the browser sends it to all of them. This is a configuration problem.
  • Different registrable domains. example.com and example.co.uk are unrelated as far as the browser is concerned. Same company, same code, same database: none of it matters. No cookie attribute spans them. This is a protocol problem.

If your regional sites are example.com and example.de, stop looking for the cookie setting. It does not exist. Skip to section 6.

2. How the Cookie Domain Attribute Actually Scopes

A cookie set without a Domain attribute is a host-only cookie. It goes back only to the exact host that set it, so one from app.example.com never reaches uk.example.com. Set Domain and the behaviour surprises people:

Code
Set-Cookie: session=...; Domain=example.com

That cookie is sent to example.com and every subdomain of it. Not the ones you listed. Not the ones you own. Every one, including legacy-thing-nobody-maintains.example.com.

There is no allowlist. The attribute takes one value meaning "here and everything below". You cannot enumerate subdomains, and you cannot exclude one. A leading dot (Domain=.example.com) is legacy syntax browsers strip; it does not mean "subdomains only".

This is the risk. Scoping a session cookie to the parent makes every subdomain part of your authentication trust boundary:

  • A marketing site running third-party tags receives your session cookie on every request.
  • staging.example.com receives production session cookies if a user visits both.
  • Any product handing users a subdomain, such as <customer>.example.com, becomes a session theft vector: the customer controls a host inside your cookie scope.
  • A subdomain abandoned and later taken over via a dangling DNS record inherits your session cookies.

HttpOnly helps but does not save you. It stops JavaScript reading the cookie. It does nothing about the cookie being sent to the untrusted subdomain, where server-side code you do not control reads it out of the request header.

My rule: only scope a cookie to the parent if you would be comfortable handing it to every subdomain you will ever create. If any subdomain is untrusted or outside your team's control, this pattern is not available to you.

3. The Cookie Attributes That Matter

AttributeEffectGuidance
HttpOnlyHidden from JavaScript (document.cookie).Always set. Closes the easiest XSS path to account takeover.
SecureOnly sent over HTTPS.Always set. Required if SameSite=None.
DomainAbsent means host-only. Present means that host plus all subdomains.Omit unless deliberately sharing.
PathOnly sent for matching URL paths.Weak boundary; same-origin JavaScript reaches across paths. Tidiness, not security.
Max-AgeLifetime in seconds. Absent means it dies with the browser session.Keep short and refresh. Long-lived cookies are long-lived liabilities.
SameSite=StrictNever sent cross-site, including plain navigation.Strongest CSRF protection. Inbound links arrive logged out.
SameSite=LaxSent on top-level GET navigation, not cross-site POSTs or subresources.The sensible default, and what browsers assume if you omit it.
SameSite=NoneSent on all cross-site requests. Must pair with Secure or the browser rejects it.Only when genuinely needed. It widens CSRF exposure.

One clarification, because the naming misleads: SameSite is about site, not origin, and site means registrable domain. app.example.com and uk.example.com are the same site, so requests between them are not cross-site and Lax or even Strict works fine. You do not need SameSite=None to share a cookie across subdomains of one parent. Reaching for None unnecessarily is a common and costly mistake.

Correct options for the subdomain-sharing case. Omit domain entirely and you get the safer host-only default:

javascript
res.cookie("session", sessionId, { httpOnly: true, // no JavaScript access secure: true, // HTTPS only sameSite: "lax", // sufficient across subdomains of one parent domain: "example.com", // shared with ALL subdomains, deliberately path: "/", maxAge: 60 * 60 * 1000 // one hour, refreshed on activity });

4. The Public Suffix List

You cannot set a cookie on a public suffix: a domain under which the public can register names, such as com, co.uk, org.au, and the shared hosting domains some static site platforms use.

Browsers maintain this list to stop a site setting Domain=com and harvesting cookies from the entire internet. Try it and the cookie is discarded silently: no error, usually no warning, just a cookie that is never stored. So example.co.uk and other.co.uk can never share cookies. By design.

The trap: if you host on a shared platform domain, your "parent" may itself be a public suffix, and parent-scoped cookies fail in a way that looks like a bug in your code. Check the list before debugging further.

5. CSRF Does Not Go Away

Cross-site request forgery (CSRF) is when another site causes a user's browser to send an authenticated request to yours. The browser attaches cookies automatically, so the request looks legitimate. SameSite=Lax blunts most classic CSRF, which is why browsers made it the default. It is not a complete defence:

  • SameSite=None turns the protection off entirely.
  • Same-site is not same-origin. With a parent-scoped cookie, a request from blog.example.com to api.example.com is same-site, and both Lax and Strict allow it. If blog.example.com is compromised or hosts user content, it can forge authenticated requests at your API and SameSite will not stop it.
  • Lax still permits cross-site GET navigation with the cookie attached. Any GET that changes state is exposed.

Keep the real controls: CSRF tokens (a per-session secret the server issues and the client echoes back in a header or form field), origin checks on state-changing requests, and the discipline that GET never mutates anything. SameSite is defence in depth, not the defence.

6. Token Handoff for Genuinely Cross-Domain SSO

Now the case where cookies cannot help: example.com and example.co.uk. The pattern is a central identity provider. One host owns the authoritative session, and other domains obtain their own local session by asking the provider through a redirect.

  1. A user signed in at id.example.com navigates to example.co.uk.
  2. example.co.uk has no session, so it redirects the browser to id.example.com with a note about where to return.
  3. id.example.com sees its own cookie, recognises the user, and issues a short-lived, single-use code, redirecting back with the code in the URL.
  4. example.co.uk exchanges the code over a back channel (server to server, not through the browser) for the user's identity.
  5. example.co.uk sets its own host-only session cookie, and the code is burnt.

The properties that make this safe are worth naming, because dropping any one breaks it:

  • Short lived. Seconds, not minutes. It only has to survive one redirect.
  • Single use. The first exchange consumes it, so a replay fails.
  • Bound to the destination. Issued for example.co.uk, rejected if presented by anyone else.
  • Redirect targets allowlisted. Otherwise an attacker asks the provider to redirect to their own site with a valid code attached. Match against an exact list of registered URLs.
  • Exchanged over a back channel. What the browser sees is worthless alone.

That last point deserves the strongest emphasis available. Never put a long-lived token in a URL. URLs are not private. They land in access logs and every proxy in between, in the Referer header sent to third parties, in synced browser history, in bookmarks and screenshots and support tickets. A short-lived single-use code survives this because it is worthless by the time it reaches the log. A session token in a URL is a credential leak with a delivery mechanism attached.

7. Use OAuth 2.0 and OIDC

Section 6 describes, roughly, the OAuth 2.0 authorization code flow with OpenID Connect layered on top for identity. That is not a coincidence. The standards encode decades of lessons about how this goes wrong.

I would reach for an established identity provider, or a mature library implementing these standards, before writing a handoff by hand. Not because the flow is conceptually hard, the sketch above fits in a paragraph, but because the security lives in details that are easy to omit and hard to notice omitting: state parameters for CSRF on the redirect itself, nonce handling, PKCE, strict redirect URI matching, token signature and audience validation, clock skew, key rotation.

A hand-rolled flow that gets the happy path right and one of those wrong looks fine in testing. It works, so nobody looks again.

8. Logout Is the Part Everyone Forgets

Sign-in gets designed. Sign-out gets an endpoint that clears a cookie.

With independent sessions on several domains, clearing one cookie signs the user out of one domain. The others carry on. A user who clicks "sign out" on a shared computer, sees a confirmation, and walks away is still signed in elsewhere. That is worse than offering no logout at all, because the confirmation was a promise.

Two things to get right.

  • Every local session must be told to end. OIDC front-channel logout loads logout URLs on each participating domain from the provider's page. Back-channel logout does it server to server: more reliable, but each site must be able to find and destroy a session by identifier.
  • Server-side revocation. If a session is a signed token the server merely validates, you cannot revoke it; it stays valid until it expires. That is the trade-off of stateless tokens. Mitigate with short lifetimes plus a revocation list for the cases that matter.

A password change should end every session everywhere, and so should an account lock. If your architecture cannot express "end every session for this user, now, across all regions", you have a gap that will matter exactly once, on the worst day.

9. Data Residency

A shared session across regions has a compliance shape that is easy to miss while staring at cookie attributes. If uk.example.com and ae.example.com share a session, something crosses a border: the session identifier and whatever the session record contains. If the store is a single global instance, personal data in it lives wherever that instance lives. Under regimes that constrain where personal data may be processed or stored, this turns a login feature into a legal question.

Two things reduce the exposure. Keep sessions local to each region, using the identity provider only to establish identity at sign-in, so the record never crosses the border and only a short-lived assertion does. And keep the record minimal: an opaque identifier and a user reference, no names or email addresses. Be deliberate about where the provider itself lives, since it holds credentials for everyone.

Decide this before you build. "Our sessions are global" is hard to reverse once regional data is entangled with it.

10. Local Development

localhost behaves differently, and the differences produce a specific class of bug: it works locally, it fails in staging.

  • Subdomains of localhost. app.localhost and uk.localhost resolve on some systems and not others, varying by browser and OS resolver. If your architecture depends on subdomain cookie sharing, testing on bare localhost does not exercise it at all.
  • localhost is a secure context even over plain HTTP, so Secure cookies work there but are silently dropped on any other hostname over HTTP. Code that works on localhost:3000 and fails on an internal HTTP hostname is usually this.
  • Ports are not part of the site. localhost:3000 and localhost:4000 are different origins but the same site, and cookies do not isolate by port. Two local apps share cookies, masking a scoping bug or manufacturing a phantom one.

Make local development resemble production in the ways that matter. Map hostnames like app.example.local and uk.example.local in your hosts file, terminate TLS with a self-signed certificate, and let the browser apply the rules it will apply in production. The cost is an afternoon. The alternative is finding cookie scoping issues where you cannot attach a debugger.

Conclusion

The decision tree is short.

Same registrable domain? You can share a cookie. Set Domain to the parent, keep HttpOnly and Secure on, use SameSite=Lax, and accept that every subdomain you ever create sits inside your authentication trust boundary. If you cannot accept that, do not share the cookie.

Different registrable domains? Cookies are not an option. Use a central identity provider with a short-lived, single-use, audience-bound code exchanged over a back channel. Prefer OAuth 2.0 and OIDC to inventing the flow. Never let a long-lived token touch a URL.

Either way: keep CSRF tokens, design logout alongside login, decide where session data may live before writing code, and make your development environment tell you the truth.

None of this is difficult. It is precise, and the precision is not optional: browsers implement all of it exactly as specified, whether or not you read the spec first.


Set the narrowest scope that works, then widen only when something breaks and you understand why.

Subscribe to my newsletter

Coming soon.

||