Guides
Build guide

Session cookie not shared across subdomains

Signed in on app.example.com, signed out on admin.example.com. An attribute-by-attribute audit of the Set-Cookie header that decides it.

Dharmendra Jagodana9 min read

In short

A session cookie fails to cross subdomains for one of five reasons, and Domain is only the first. Omit Domain and the cookie is host-only. Path defaults to a directory, not to slash. SameSite is almost never the cause. And on vercel.app no Domain value works at all.

You are signed in on app.example.com. You open admin.example.com and you are signed out. Same browser, same tab group, one Set-Cookie that the second host never receives.

Five attributes on that header decide whether it crosses, and usually exactly one of them is wrong. None of them fail loudly. A cookie the browser refuses is not a console error, it is a row missing from a table you were not looking at.

Before the audit, settle who wrote the header. If you are on BuildBase, we did not. res.cookie( appears nowhere in the tenant server or the central server, and two unit tests exist only to keep it that way - server/test/unit/cookie-auth.test.ts and central-server/test/unit/18-cookie-auth.test.ts both walk every source file and fail if one shows up. The SDK carries the session as an x-session-id header instead, and your getSession callback decides where that id lives. So the cookie is yours. Which means every attribute below is a value you picked, including the ones you picked by leaving out.

The audit

AttributeWhat it looks like in devtools when it is wrongWhat it does, quietly
Domainthe column is blank, or shows the exact hostHost-only. The cookie is fine, and the sibling subdomain simply never gets sent it
SameSiteNone on a cookie you only use across your own subdomainsNothing you wanted. It forces Secure and opts you into third-party cookie blocking you did not need
Secureunchecked, or checked while you test against a LAN IPOver plain http off localhost the browser drops the whole header and stores nothing
Path/api/auth where you expected /Sent only under that prefix. Domain can be perfect and /dashboard still sees nothing
HttpOnlychecked, and document.cookie comes back emptyNothing at all to sharing. It is why your console check is lying to you
__Host- prefixthe row is not in the tableThe browser rejected the cookie entirely, because __Host- forbids Domain

Open Application, then Cookies, and click the subdomain that is signed out rather than the one that works. Half of these are only visible from the host that is failing.

Domain: the attribute you get wrong by leaving out

Omit Domain and you get a host-only cookie. It is stored, it is sent back to the exact host that set it, and it is invisible to every sibling. That is the default, and it is the single most common version of this bug.

The leading dot is a distraction. RFC 6265 strips it, so Domain=.example.com and Domain=example.com produce the same cookie with the same scope, and Chrome renders the dot back to you either way. The question is never which spelling. It is whether the attribute is there.

Our consent cookie is the one place in this repo that writes a domain-scoped cookie, and it decides conditionally:

// packages/ui/src/components/analytics/consent.ts
const APEX = 'buildbase.app';

function cookieDomain(): string {
  if (typeof window === 'undefined') return '';
  const host = window.location.hostname;
  return host === APEX || host.endsWith(`.${APEX}`) ? `.${APEX}` : '';
}

That comparison is deliberately against a literal apex rather than a derived registrable domain, so a self-hosted tenant never gets a cookie written up to their own apex by our code. It also means the domain is a constant in source. There is no COOKIE_DOMAIN variable anywhere in this repo, and grep for one returns nothing, because nothing on our side needs it.

If you reach for an env var in your own app, know what an unset one costs here. notes/ENVIRONMENT.md documents the trap: the heredoc in docker-server.yml is the allowlist, env_file forwards the whole .env, and a variable the code reads but the heredoc omits is not "using its GitHub value". It is unset, and it falls back to the default. An unset cookie domain does not throw. It writes a host-only cookie, and your admin subdomain is signed out in production and nowhere else.

One more thing about Domain, and it is the part that wastes an afternoon: a host-only expiry does not delete a domain-scoped cookie. Widen the scope, reload, and you now have two same-named cookies at two scopes, whose read order is not well defined. Our consent cookie went from v1 to v2 partly for this reason, and the code still clears the legacy names at both scopes on every write.

Warning

Scoping a session cookie to the apex means every subdomain can read it, including ones you do not control - a marketing host on a vendor, a status page, a stale CNAME someone pointed at a third party. The cookie-auth test names this exact shape: a cookie written by any sibling host that the browser would then attach to the API.

SameSite: almost certainly not your problem

app.example.com and admin.example.com are the same site. SameSite is computed on the registrable domain, not the origin, so Lax sends the cookie between your subdomains without complaint. If you arrived here from a forum thread telling you to set SameSite=None, that thread was answering a different question.

None is for genuinely cross-site: two different registrable domains. It requires Secure, and it puts the cookie in the category Safari blocks and Firefox partitions by default. You trade a bug you have for a bug you cannot fix from your own code.

We hit the real cross-site case and did not solve it with a cookie. The hosted sign-in pages sit on a different registrable domain from the app that sent the visitor there, so the consent answer written on the app is unreadable at sign-in. The comment on IAuthData.trackingConsent in packages/shared/src/types/auth.ts spells out the choice: carry the value in the auth state rather than in a cookie or a redirect URL, because a value in the URL is one the visitor can edit into a yes they never gave.

Secure: the one that only breaks in one place

Secure means https only, with one exception that saves you and one that catches you. Chrome and Firefox treat http://localhost as a trustworthy origin and will store a Secure cookie there. They do not extend that to a LAN IP.

That matters more than it sounds if you test on a phone. Our own setup wizard supports 192.168.x.x and LAN addresses on purpose, so the habit exists. A Secure cookie that works on localhost and works in production disappears the moment you load the same app from your laptop's LAN IP on another device, and the symptom is identical to the one you came here for.

Also worth knowing in the other direction: SameSite=None without Secure is rejected outright. If you took the forum advice above and only did half of it, the cookie is not weakened, it is gone.

Path: the default is not /

Path is subdomain-independent, which is why it survives a Domain fix and keeps you stuck.

The RFC default path is the directory of the request URI, not /. A Set-Cookie returned from POST /api/auth/callback with no Path defaults to /api/auth, and /dashboard never sees it. Express sets Path to / for you, which is why most people never meet this, but nothing in the spec requires that and anything hand-rolling the header certainly does not bother.

Matching is by path segment: Path=/admin covers /admin and /admin/x, not /administrators. Set it to / and stop thinking about it.

HttpOnly: not a sharing attribute at all

HttpOnly changes nothing about which host receives the cookie. It changes whether document.cookie can see it, which is why it belongs in this audit: the reader debugging with console.log(document.cookie) gets an empty string and concludes the cookie was never set.

Key takeaway

document.cookie cannot see an HttpOnly cookie. An empty string in the console is not evidence the cookie is missing. Check Application, then Cookies, on the failing host instead.

There is a structural version of this worth naming, and it is in our own repo: our hosted sign-in app sets output: 'export' in website-auth/next.config.js. A static export has no Node server, no route handlers and no middleware, so it cannot set an HttpOnly cookie on any attribute at all. It keeps its auth state in localStorage (website-auth/src/services/axios/base.ts) and sends the token in a header, which is the pairing the cookie-auth test argues for: a header credential is CSRF-safe because a cross-site page cannot set Authorization, and no API route in this codebase carries a CSRF token because nothing needs one.

If your framework cannot write the header, no attribute table is going to help you. Find that out before you spend an hour on Domain.

__Host- and __Secure-: a prefix that forbids what you want

__Host- is incompatible with subdomain sharing by design. The browser accepts a __Host- cookie only when it is Secure, came from a secure origin, has Path=/, and carries no Domain attribute whatsoever. Add the Domain you came here to add and the cookie is refused. No error, no row in the table, and a name that looks like it should have worked.

__Secure- is the prefix you want. It only requires the Secure flag and is perfectly happy with a Domain.

We use neither today - grep for __Host- and __Secure- across the repo returns nothing, which follows from setting no cookies at all. Here is the shape if you are adding one yourself, in a Next.js route handler. signIn is your own function; the rest is as written. I have not run this against a deployment, because nothing in this repo sets a cookie to run it against:

import { cookies } from 'next/headers';

// Your variable, not one of ours. Set it to example.com, no leading dot needed.
const COOKIE_DOMAIN = process.env.SESSION_COOKIE_DOMAIN;

export async function POST(req: Request) {
  const { sessionId } = await signIn(req);

  cookies().set('__Secure-app-session', sessionId, {
    domain: COOKIE_DOMAIN, // absent means host-only, and the sibling is signed out
    path: '/', // not the RFC default, set it explicitly
    httpOnly: true,
    secure: true,
    sameSite: 'lax', // already same-site: admin.example.com is example.com
    maxAge: 60 * 60 * 24 * 30, // 30 days, matching the platform session TTL
  });

  return Response.json({ ok: true });
}

30 days is the platform's own session lifetime - SESSION_DEFAULTS.sessionTTL in packages/shared/src/constants/platform-stats.ts - so a cookie that expires sooner will sign people out while the session behind it is still valid. That is its own confusing bug report.

The one the attribute list cannot fix

On something.vercel.app, no Domain value shares the cookie. Not .vercel.app, not vercel.app, not any spelling of it.

vercel.app is on the Public Suffix List, alongside github.io, pages.dev and netlify.app. Browsers refuse to store a cookie scoped to a public suffix, because otherwise any preview deployment could write a cookie readable by every other customer's preview on the same host. The refusal is silent. You get exactly the symptom you came here with, and every attribute in the table above is already correct.

Our own consent test asserts this host boundary directly - buildbase.vercel.app sits in the list of hosts where the cookie must stay host-only, next to localhost and a customer domain, in packages/ui/tests/affiliate.test.ts. We scope to our apex explicitly rather than deriving it, so the test is checking our code. The browser would enforce the same outcome regardless.

The fix is a custom domain, and it is not a settings change on the cookie. Put both hosts under one registrable domain you own and the Domain attribute starts working again. Our production layout is exactly that. Five of the hosts listed in packages/shared/src/constants/app-config.ts are console.buildbase.app, api.console.buildbase.app, central.console.buildbase.app, docs.buildbase.app and www.buildbase.app - one registrable domain, so a single Domain=buildbase.app reaches every one of them.

One footnote if you were planning to send that cookie to our API. The public SDK routes in server/src/routes/v1/index.ts send Access-Control-Allow-Origin: * and no Access-Control-Allow-Credentials, so a fetch with credentials: 'include' fails the CORS check by construction. A wildcard origin and credentials are mutually exclusive in every browser. Send x-session-id, which is already on the allowed-headers list next to Authorization and x-org-id.

If you are still wiring the surrounding pieces, the whole Next.js setup, from the route handler that sets this cookie to the getSessionId that reads it, is in BuildBase on Next.js. The workspace side of this is in add multi-tenant workspaces in an afternoon, the header-auth pattern shows up again in enforcing quotas server-side, and the argument for not hand-rolling any of it is in the cost of building auth yourself.

Install

npm i @buildbase/sdk
auth
cookies
nextjs

Frequently Asked Questions

Does SameSite=Lax block cookies between subdomains?

No. SameSite is computed on the registrable domain, and app.example.com and admin.example.com are both example.com, so they are the same site. Lax sends the cookie between them normally. Switching to None fixes nothing and forces Secure on you.

Can I use a __Host- prefixed cookie across subdomains?

No, and the incompatibility is the point of the prefix. A __Host- cookie is rejected outright if it carries a Domain attribute at all, so it is pinned to one host by construction. Use __Secure- instead, which only requires the Secure flag.

Why does my cookie work on my custom domain but not on the vercel.app preview?

vercel.app is on the Public Suffix List, so the browser refuses any cookie scoped to it. No Domain value shares a cookie between two *.vercel.app hosts. The fix is a custom domain on the preview, not a cookie setting.

Does BuildBase set the session cookie for me?

No. res.cookie appears nowhere in either server, and two unit tests assert that it stays that way. The SDK sends the session as an x-session-id header, and your own getSession callback decides where the id is stored. Every attribute below is yours.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime