Isolating Tenants on Shared SSE Endpoints Permalink to this section

Part of Security Headers for Event Streams, under SSE Protocol Fundamentals & Architecture.

A multi-tenant product usually serves every customer’s live updates from the same Server-Sent Events endpoint and the same fan-out infrastructure. That sharing creates two risks request/response APIs handle more easily: leakage, where one tenant receives another tenant’s events, and starvation, where one tenant’s volume degrades everyone else’s streams. Long-lived connections make both harder, because authorisation happens once at connect time and then the connection lives for hours. This guide covers the design that keeps tenants apart on shared streams.

Symptom & Developer Intent Permalink to this section

  • A security review finds that changing a query parameter on the stream URL shows another organisation’s events.
  • A user removed from a workspace keeps receiving its live updates until they reload the page.
  • One large customer’s bulk import makes live updates lag for every other customer.
  • A CDN or proxy served one user’s stream response to another.
  • Logs contain event payloads from many tenants mixed together.

The intent is streams whose content is determined solely by the authenticated identity, whose access is revocable within seconds, and whose capacity is fairly divided between tenants.

Root Cause Analysis Permalink to this section

Leakage almost always comes from letting the client choose what to subscribe to: ?tenant=acme or ?channel=org:42 taken from the request and used directly as a pub/sub channel. The server authenticates the user but never checks that the requested channel belongs to them. Revocation failures come from checking authorisation only at connect time. Starvation comes from shared queues and fan-out loops with no per-tenant accounting.

Channels come from identity, never from input Flow from the authenticated session through server-side membership lookup to the set of channels the stream subscribes to, with client-supplied channel names ignored. Channels come from identity, never from input Session / token user + tenant verify Membership lookup server side resolve Allowed channels tenant:42, user:17 subscribe Subscribe only those ?channel= from the URL is at most a filter within the allowed set, never an addition to it
The client may ask for a view, but the server decides which channels that view maps to for this identity.

Step-by-Step Resolution Permalink to this section

Step 1 — Derive every channel from the authenticated identity Permalink to this section

app.get('/api/stream', requireSession, async (req, res) => {
  const { userId, tenantId } = req.session;                 // from a verified session, not the URL
  const rooms = await membership.roomsFor(userId, tenantId); // server-side lookup

  // The client may request a narrower view; intersect, never union.
  const requested = new Set(String(req.query.rooms ?? '').split(',').filter(Boolean));
  const channels = [
    `t:${tenantId}:broadcast`,
    `t:${tenantId}:u:${userId}`,
    ...rooms.filter((r) => !requested.size || requested.has(r)).map((r) => `t:${tenantId}:r:${r}`),
  ];
  openStream(res);
  const sub = await bus.subscribe(channels, (msg) => res.write(msg));
  req.on('close', () => sub.unsubscribe());
});

Prefix every channel with the tenant id. Even if a room id were guessed or reused across tenants, the prefix keeps channels distinct.

Step 2 — Revoke access on live streams Permalink to this section

Membership changes must reach open streams. Publish a control message on the user’s channel when access changes, and have the node close or re-scope the stream:

// When a user is removed from a room or tenant:
await bus.publish(`t:${tenantId}:u:${userId}`, JSON.stringify({ type: 'revoke', rooms: [roomId] }));

// On the node, in the stream's message handler:
if (msg.type === 'revoke') {
  res.write('event: access-changed\ndata: {}\n\n');
  res.end();                                   // client reconnects; connect-time checks re-run
}

Also bound stream lifetime to credential lifetime: end the stream when the session or token expires, so a reconnect re-authenticates.

Revoking a user's access to a room with an open stream Sequence diagram of an admin removing a user from a room, the API publishing a revoke message on the user's channel, the SSE node ending the stream, and the client reconnecting with a narrower subscription. Revoking a user's access to a room with an open stream Admin API Bus SSE node Client remove user 17 from room 9 publish t:42:u:17 revoke revoke access-changed, end reconnect (room 9 excluded)
The stream is ended within a second of the change. The reconnect re-runs the membership lookup, so the removed room is no longer included.

Step 3 — Enforce per-tenant quotas Permalink to this section

const tenantConns = new Map();
function admit(tenantId, limit) {
  const n = tenantConns.get(tenantId) ?? 0;
  if (n >= limit) return false;
  tenantConns.set(tenantId, n + 1);
  return true;
}

Apply limits to concurrent streams per tenant (from their plan), to publish rate per tenant, and to fan-out work per tenant. For fan-out, process each tenant’s messages in its own queue and drain queues round-robin, so a burst from one tenant is spread over time instead of delaying every other tenant’s events behind it. Per-user fair queuing for SSE broadcasts implements the same idea at user level.

p95 event delay for small tenants during a large tenant's bulk import Bar chart comparing p95 event delay experienced by small tenants during a bulk import by one large tenant, with a shared FIFO queue and with per-tenant queues drained round-robin. p95 event delay for small tenants during a large tenant's bulk import Shared FIFO fan-out 38 s Per-tenant queues, round-robin 0.2 s p95 seconds from publish to delivery, small tenants
A shared queue makes everyone wait behind the largest tenant. Per-tenant queues keep small tenants' latency independent of it.

Step 4 — Make streams uncacheable and unshareable Permalink to this section

res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-store',                  // never store, anywhere
  'Vary': 'Cookie, Authorization',
  'X-Accel-Buffering': 'no',
});

A CDN configured to cache by URL, or to coalesce identical in-flight requests, could otherwise hand one user’s personalised stream to another. Keep personalised streams on routes the CDN bypasses entirely.

Step 5 — Keep tenant data out of shared logs Permalink to this section

Log stream lifecycle with tenant and user ids, not payloads. If payload logging is needed for debugging, route it to a per-tenant, access-controlled store.

Step 6 — Check authorisation on the publish side too Permalink to this section

Isolation has two ends. The subscribe side decides which channels a stream reads; the publish side decides which channel an event is written to. A bug in a publisher — an event for tenant A written to tenant B’s channel because an id was taken from the wrong object — leaks data just as effectively as a subscribe-side bug. Centralise publishing in one function that takes the tenant from the domain object being changed, never from request input, and assert it:

export async function publishForEntity(entity, type, payload) {
  const tenantId = entity.tenantId;                       // from the stored entity, not the caller
  if (!tenantId) throw new Error('entity without tenant cannot be published');
  await bus.publish(`t:${tenantId}:r:${entity.roomId}`, JSON.stringify({ type, payload }));
}

A periodic test that publishes an event for one tenant and asserts that a stream authenticated as another tenant receives nothing, running against a staging environment with production-like routing, catches regressions on both ends at once.

Validation & Monitoring Permalink to this section

# Tampering test: request another tenant's room with your own session; expect it to be ignored.
curl -sN -b tenantA.txt 'https://app.example.com/api/stream?rooms=tenantB-room-1' | head -5

# Revocation test: remove membership while streaming; the stream must end within seconds.

Automate both as integration tests. In production, count revocations delivered to live streams and the time from membership change to stream end; alert if the latter exceeds a few seconds.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is authenticating the connection enough?

No. Authentication establishes who the user is; the server must also decide which channels that user may see and re-check when membership changes during the stream's lifetime.

Should each tenant get its own SSE endpoint?

It is not necessary for isolation if channels are derived correctly. Separate endpoints or infrastructure make sense for tenants with contractual isolation or very large volumes.

How quickly must revocation take effect?

Within seconds for security-sensitive products. A revoke message on the user's control channel achieves that; relying on the next reconnect can take hours.

Can a CDN serve multi-tenant SSE?

Only as a pass-through that neither caches nor coalesces requests. Shared, identical public streams can be fanned out at the edge; personalised ones must not be.