The cookie that logged you out of the other app: cookie name collisions in a multi-app SSO
Logging into one app silently broke another. Then our SSO session started fighting with the ERP's own session. Both bugs had the same root cause: cookies with the same name on the same host. Here is how cookies are really scoped, why names collide, and the naming scheme that fixed it.
The problem
Apps on the same domain overwrote each other's auth cookies, and the SSO session cookie collided with the ERP's own session cookie.
The result
Per-app token cookie names, one deliberately shared session cookie with a unique name, and explicit rules for Path, Domain, SameSite and Secure.
Stack
The problem
Our central auth service gives each app its own tokens, stored as HttpOnly cookies by each app's backend (see "One login, many apps"). Everything worked when we tested one app at a time.
Then people started using two apps in the same browser, and strange things happened:
- A user logged into the configurator, then opened the data source app and logged in there. Back in the configurator, their next request failed: wrong app, wrong permissions.
- Sometimes it worked, sometimes it didn't, depending on which app they had opened last.
- A bit later, a second bug appeared: after logging in through our SSO, the company ERP (which also lives on the same host) started saying the user's session was invalid, and the ERP's login kept breaking our session in return.
No error pointed at cookies. The logs showed valid tokens being rejected, and sessions disappearing. It took a while to see that both bugs were the same bug.
The details
How a browser decides which cookie is "the same"
A browser identifies a cookie by three things:
name + domain + path
If a response sets a cookie with the same name, domain and path as an existing one, the browser overwrites it. It doesn't matter which application set it.
Our apps were all served from one host, under different paths behind a gateway:
https://backend.company.com/inventory/...
https://backend.company.com/billing/...
https://backend.company.com/auth/...
And every app's backend wrote its tokens with the same generic names at Path=/:
Set-Cookie: access_token=<inventory token>; Path=/; HttpOnly; Secure
Set-Cookie: refresh_token=<inventory token>; Path=/; HttpOnly; Secure
Same name, same host, same path. When the billing app logged the user in, it set access_token again, overwriting the inventory token. Then the inventory backend received a token minted for billing: wrong app, wrong permissions, rejected.
Hence the randomness: whichever app logged in last owned the cookie.
The second collision
Our SSO session cookie was named session_id. It was scoped to Path=/ on the same host, because every app's backend needs to read it.
But the ERP lives on that same host too, and it also uses a cookie called session_id at Path=/ for its own login. So:
- logging in through our SSO overwrote the ERP's session → the ERP's "who am I" endpoint answered
invalid_session, - logging into the ERP overwrote our session → our apps lost their SSO session.
Two unrelated systems, one cookie name.
The challenges
Some cookies must be shared, some must not
This is the core design tension:
- Token cookies (access and refresh) must be private to each app. Inventory's token must never reach billing's backend as "its" token.
- The SSO session cookie must be shared across all apps. It's what lets a user open billing without logging in again.
A single naming rule for "all auth cookies" can't satisfy both.
Paths are not a security boundary
A tempting fix is "scope each app's cookie to its own path" (Path=/inventory). That helps against overwrites, but it's fragile: gateways rewrite paths, APIs are sometimes called from other paths, and cookie paths are a routing convenience, not an isolation mechanism. Any page on the host can still read or set cookies for other paths in various ways.
You don't control every cookie on the host
The ERP's session_id wasn't ours to rename. The fix had to come from our side.
Cross-site requests need the right attributes
Frontends and backends sometimes run on different origins, so cookies need SameSite=None. Browsers drop SameSite=None cookies unless they're also Secure, which can't work on plain HTTP during local development.
The solution
1. Token cookies carry the app's name
Each app's tokens now live in cookies prefixed with the app's name:
export function getAccessTokenCookieName(appName?: string): string {
return appName ? `${appName}_access_token` : 'access_token';
}
export function getRefreshTokenCookieName(appName?: string): string {
return appName ? `${appName}_refresh_token` : 'refresh_token';
}
So the browser now holds:
inventory_access_token inventory_refresh_token
billing_access_token billing_refresh_token
Different names mean different cookies. Logging into billing can no longer touch inventory's tokens, whatever the path or the order of logins.
The admin panel of the auth service itself (which is scoped to no app) keeps the generic names, because only one such session can exist on the host.
Every connected backend uses the same names the auth service writes. Whether the tokens came from a real login, a session exchange or a refresh, the browser ends up in the same state, and any route can refresh them.
2. The shared session cookie gets a unique name
The SSO session cookie stays shared on purpose (that's the whole point of SSO), but it was renamed to something nobody else on the host would use:
// Not "session_id": the ERP lives on this same host and uses that name at
// Path=/ for its own session, so the two kept overwriting each other.
export const SESSION_COOKIE = 'sso_session_id';
One rename, and two systems that had been silently fighting stopped interfering. The comment next to the constant matters as much as the rename: without it, someone will "clean it up" back to session_id one day.
3. Domain: only when apps live on different subdomains
The session cookie's Domain attribute is configurable and unset by default:
export function getSessionCookieDomain(): string | undefined {
return process.env.SESSION_COOKIE_DOMAIN || undefined;
}
- Apps on the same host, different paths (our case): leave
Domainunset. The cookie stays scoped to the exact host that set it, which is already shared by all the apps. - Apps on different subdomains (
inventory.company.com,billing.company.com): setDomain=.company.comso the session cookie reaches every subdomain.
Setting Domain when you don't need it widens the cookie's audience for no benefit, so it stays off unless the deployment needs it.
4. SameSite and Secure travel together
const isProd = process.env.NODE_ENV === 'production';
// SameSite=None cookies are dropped by browsers unless Secure is also set,
// so the two travel together: HTTPS + cross-site cookies in production,
// plain HTTP + same-site cookies for local development.
export const SECURE_COOKIES = isProd;
export const COOKIE_SAME_SITE: 'none' | 'lax' = isProd ? 'none' : 'lax';
Every auth cookie is also HttpOnly, so JavaScript, including any injected script, can't read the tokens.
5. Logout must use the same attributes
A cookie is only cleared if the "clear" uses the same name, path and domain it was set with. Logout handlers clear each cookie with exactly the attributes used to create it, including the session cookie's optional domain:
response.clearCookie(SESSION_COOKIE, {
httpOnly: true,
sameSite: COOKIE_SAME_SITE,
secure: SECURE_COOKIES,
path: '/',
domain: getSessionCookieDomain(),
});
A logout that "succeeds" but leaves the real cookie in place is one of the most confusing bugs in web development.
6. Rolling it out
Renaming cookies logs everyone out once: old cookies simply aren't read anymore. That's acceptable for internal tools, but it has to be coordinated: the auth service and every connected app must switch to the new names together, or an app still reading the old names will think nobody is logged in. Shared constants with a clear comment in each app made the migration mechanical.
Lessons learned
- A cookie is name + domain + path. Two apps on one host with the same cookie name will overwrite each other, whatever your code intends.
- "Works alone, breaks together" points at shared state. When bugs depend on which app was used last, look at cookies, local storage and caches.
- Decide which cookies are shared and which are private. Tokens: private per app. SSO session: shared on purpose.
- Namespace what you own. Prefixing cookie names with the app name is simple and effective.
- You share the host with things you don't control. Use names nobody else will choose.
- Paths are not isolation. Use names for separation; treat paths as routing.
- SameSite=None requires Secure. Configure them together, per environment.
- Clear cookies with the attributes you set them with. Otherwise, logout lies.
- Comment the reason next to the name. The next "cleanup" will undo your fix otherwise.
FAQ
Why does logging into one app log me out of another on the same domain?
Usually because both apps store their tokens in cookies with the same name, domain and path. The second login overwrites the first app's cookie. Give each app's cookies a unique name.
What makes two cookies "the same" in a browser?
The combination of name, domain and path. A new Set-Cookie with the same three values replaces the existing cookie, regardless of which application sent it.
Why is my SameSite=None cookie not being set?
Browsers reject SameSite=None cookies that aren't also marked Secure, and Secure cookies require HTTPS. In local HTTP development, use SameSite=Lax without Secure.
Should I set the Domain attribute on my session cookie?
Only if the cookie must be shared across subdomains. Without Domain, the cookie is limited to the exact host that set it, which is safer and enough when all apps share one host.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk