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.
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.
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.
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.