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.
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
| Attribute | What it looks like in devtools when it is wrong | What it does, quietly |
|---|---|---|
Domain | the column is blank, or shows the exact host | Host-only. The cookie is fine, and the sibling subdomain simply never gets sent it |
SameSite | None on a cookie you only use across your own subdomains | Nothing you wanted. It forces Secure and opts you into third-party cookie blocking you did not need |
Secure | unchecked, or checked while you test against a LAN IP | Over 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 |
HttpOnly | checked, and document.cookie comes back empty | Nothing at all to sharing. It is why your console check is lying to you |
__Host- prefix | the row is not in the table | The 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