Preventing Session Fixation Across Tenant Subdomains

Serving each tenant on its own subdomain makes cookie scope a tenant-isolation boundary, and this page shows how to keep that boundary from becoming a shared session pool. It is a focused part of Session Isolation & State Management: which cookie attributes to set, when to rotate the identifier, and how to prove a stolen cookie is worthless on a neighbouring subdomain.

Problem Framing

A cookie set with Domain=.app.example.com is sent to every host under that domain. On a platform where tenants live at acme.app.example.com and globex.app.example.com, that single attribute makes one session cookie valid for every tenant simultaneously. Everything else in the stack can be correct — Row-Level Security, tenant context middleware, scoped queries — and the session layer will still have handed the same credential to two boundaries.

Two distinct attacks follow. Session fixation: an attacker sets a known session identifier on the parent domain (via a subdomain they control, or a stale wildcard record), waits for the victim to authenticate, and then uses the identifier they already know. Cross-subdomain replay: a cookie legitimately issued for one tenant is presented to another, and if tenant scope is derived from the cookie rather than checked against the host, the request succeeds.

The fix is layered: make cookies host-only so they never travel sideways, bind the session record to the host that created it, and rotate the identifier at every authentication and privilege change so a pre-authentication value can never survive into an authenticated state.

Step-by-Step Guide

1. Issue host-only cookies with the __Host- prefix

Omitting Domain makes a cookie host-only. Adding the __Host- prefix makes browsers enforce that: they reject the cookie unless it is Secure, has Path=/, and carries no Domain attribute, so a misconfiguration fails loudly instead of silently widening scope.

// session-cookie.ts
import type { Response } from 'express';

export function setSessionCookie(res: Response, sid: string, maxAgeSec = 60 * 60 * 8) {
  res.cookie('__Host-sid', sid, {
    httpOnly: true,
    secure: true,        // required by the __Host- prefix
    sameSite: 'lax',     // 'strict' if no cross-site POST flows are needed
    path: '/',           // required by the __Host- prefix
    maxAge: maxAgeSec * 1000,
    // Deliberately NO domain: host-only is the isolation boundary.
  });
}

If the platform genuinely needs a shared login host, keep the shared credential and the tenant session separate: the identity provider host issues a short-lived, single-use exchange code, and each tenant host trades it for its own host-only session cookie.

2. Bind the session record to the host that created it

Cookie scope is a browser-side control, and browsers are not the only clients. Store the issuing host on the session and reject presentation from anywhere else, so a copied cookie value is useless outside its tenant.

CREATE TABLE app_session (
  sid           text PRIMARY KEY,           -- opaque, 256 bits of entropy
  tenant_id     uuid NOT NULL,
  user_id       uuid NOT NULL,
  issued_host   text NOT NULL,              -- 'acme.app.example.com'
  issued_at     timestamptz NOT NULL DEFAULT now(),
  last_seen_at  timestamptz NOT NULL DEFAULT now(),
  auth_epoch    integer NOT NULL DEFAULT 0,
  expires_at    timestamptz NOT NULL
);

CREATE INDEX ON app_session (tenant_id, user_id);
// validate.ts
export async function loadSession(sid: string, host: string) {
  const s = await sessions.get(sid);
  if (!s) return null;
  if (s.issuedHost !== host) {                 // presented on the wrong tenant host
    await audit.write('session.host_mismatch', { sid, expected: s.issuedHost, got: host });
    await sessions.destroy(sid);               // treat as compromised, not as a mistake
    return null;
  }
  if (s.expiresAt < new Date()) return null;
  return s;
}

Destroying the session rather than merely rejecting the request is deliberate: a cookie presented on the wrong host is either an attack or a serious bug, and neither warrants keeping the credential alive.

3. Rotate the identifier at every privilege transition

Fixation depends on an identifier surviving from before authentication to after it. Issue a new identifier — and invalidate the old one — at login, at multi-factor completion, at tenant switch, and at any role change.

// rotate.ts
export async function rotateSession(oldSid: string, changes: Partial<SessionFields>) {
  const old = await sessions.get(oldSid);
  if (!old) throw new Error('no session to rotate');
  const sid = randomBase64Url(32);                    // 256 bits
  await sessions.create({ ...old, ...changes, sid, issuedAt: new Date() });
  await sessions.destroy(oldSid);                      // old value is dead immediately
  return sid;
}

// at login
const sid = await rotateSession(req.cookies['__Host-sid'], { userId, authEpoch: user.authEpoch });
setSessionCookie(res, sid);

The same rotation path handles role changes, which is why it should live next to the invalidation machinery described in invalidating tenant sessions on role change.

4. Namespace the session store per tenant

Even with correct cookies, a shared cache keyed only by identifier invites a collision or a wildcard scan that spans tenants. Prefix every key with the tenant, matching the convention used across the platform.

// store.ts
const key = (tenantId: string, sid: string) => `sess:${tenantId}:${sha256(sid)}`;

export async function put(tenantId: string, sid: string, value: SessionFields, ttlSec: number) {
  await redis.set(key(tenantId, sid), JSON.stringify(value), 'EX', ttlSec);
}

Hashing the identifier before it becomes a key means a leaked store dump does not hand out live session credentials, and the tenant prefix means an operational SCAN for one tenant cannot enumerate another's.

Verification

The decisive test is negative: a cookie issued on one tenant host must be rejected on another. Assert that in an automated test rather than checking it by hand once.

// session-scope.spec.ts
it('rejects a session cookie replayed on another tenant host', async () => {
  const login = await request('https://acme.app.test').post('/login').send(creds);
  const cookie = login.headers['set-cookie'].find((c: string) => c.startsWith('__Host-sid='));

  const replay = await request('https://globex.app.test').get('/api/me').set('Cookie', cookie!);
  expect(replay.status).toBe(401);

  // and the replayed session is now dead everywhere
  const original = await request('https://acme.app.test').get('/api/me').set('Cookie', cookie!);
  expect(original.status).toBe(401);
});

it('never sets a Domain attribute on the session cookie', async () => {
  const login = await request('https://acme.app.test').post('/login').send(creds);
  const cookie = login.headers['set-cookie'].join(';');
  expect(cookie).not.toMatch(/domain=/i);
  expect(cookie).toMatch(/__Host-sid=/);
});

At runtime, watch for host mismatches directly — they should be identically zero, so any non-zero count is either an attack or a routing bug worth paging on.

SELECT   date_trunc('hour', at) AS hour, count(*) AS mismatches
FROM     audit_event
WHERE    action = 'session.host_mismatch' AND at > now() - interval '7 days'
GROUP BY 1 ORDER BY 1 DESC;

Failure Modes & Gotchas

FAQ

Can tenants share one login host and still be isolated? Yes, provided the shared host never issues the session credential the tenant hosts accept. The pattern is a code exchange: the login host authenticates, then redirects to the tenant host with a single-use, short-lived code that the tenant host redeems for its own host-only cookie. The shared host's own cookie proves identity to the login flow only, and is useless as an application session.

Is SameSite=Strict enough on its own? No. SameSite governs cross-site requests, and every subdomain of one registrable domain is the same site — so acme.app.example.com and globex.app.example.com are same-site to the browser. It is a valuable defence against cross-site request forgery and does nothing at all for cross-subdomain scope. Host-only cookies are the control that addresses this problem.

What about wildcard TLS certificates and subdomain takeover? A wildcard certificate makes any subdomain plausible to a browser, so a dangling DNS record pointing at a decommissioned service becomes a way to set cookies on the parent domain. Host-only cookies neutralise the cookie half of that risk, but the takeover itself remains a problem: audit DNS records for hosts that no longer resolve to infrastructure you control, and prefer per-host certificates where the operational cost allows.

Does a single-page application change any of this? Not materially, because the controls all live on the server side. The cookie is still set by a server response, the host binding is still checked server-side, and rotation still happens at the same privilege transitions. What does change is the failure experience: a single-page application holding an invalidated session will keep rendering until its next request fails, so the client needs to treat a 401 on any request as a signal to clear local state and restart authentication rather than retrying quietly. Storing tokens in JavaScript-accessible storage instead of an HttpOnly cookie is a separate and considerably worse decision, and none of the protections here apply to it.