Multi-Tenant WebSocket Channel Namespacing #

You run one WebSocket fleet for many customer accounts, and a support ticket just landed: a user from tenant acme received a deal.updated event that belonged to tenant globex. Nobody got hacked — the socket simply subscribed to a channel name it should never have been allowed to name. On a shared fleet, channel names are a security boundary, and if subscription is unguarded, the boundary is decorative. This page covers how to namespace channels as tenant:{id}:{topic}, enforce that a socket may only subscribe within its own tenant, keep cross-tenant traffic out of your Redis fan-out, and apply rate limits per tenant so one noisy account can’t starve the rest.

Root cause #

Cross-tenant leakage is almost never a bug in the broadcast code — it’s a missing check between “the client asked to subscribe to channel X” and “the server began forwarding X to this socket.” Three concrete failure shapes:

  1. Trusting client-supplied channel names. The client sends { "subscribe": "tenant:globex:deals" } and the server adds it to a subscription set verbatim. The socket’s own tenant is on the authenticated session, but it’s never compared against the channel prefix. Any string the attacker can type becomes a subscription.

  2. Shared fan-out without a tenant filter. When every node subscribes to a broad Redis pattern like tenant:* (or worse, a single global channel) and then re-broadcasts to all local sockets, the fan-out path itself crosses tenants. The leak happens server-side regardless of what the client requested.

  3. No per-tenant accounting. Limits applied per-connection or globally let a single tenant open thousands of sockets or flood subscriptions, degrading latency for every other account on the box. This isn’t leakage but it’s the same root: the tenant identity isn’t a first-class key in the routing layer.

The fix is to make the tenant ID — derived from the authenticated session, never from the message — the prefix of every channel, and to validate that prefix on every subscribe. Channel naming is downstream of WebSocket authentication and authorization: the upgrade establishes who the socket is; namespacing decides what it may name. The pattern tenant:{id}:{topic} gives you a parseable, prefix-matchable key that travels cleanly through your Redis Pub/Sub fan-out.

Tenant namespacing guard on subscribe A socket authenticated as tenant acme is allowed to subscribe to tenant:acme channels and rejected when it requests tenant:globex channels; Redis fan-out is keyed by tenant prefix. Socket session.tenant = acme subscribe tenant:acme:deals subscribe tenant:globex:deals ALLOW prefix matches DENY 4403 cross-tenant Redis fan-out key = tenant prefix

Resolution #

The guard below runs on every subscribe message. It parses the channel into tenant/topic, compares the tenant segment against the value baked into the authenticated session at upgrade time, and only then registers the subscription and joins the Redis fan-out for that exact channel. Per-tenant counters cap subscriptions and message rate so one account stays inside its own envelope.

import { WebSocketServer, WebSocket } from "ws";
import Redis from "ioredis";

const MAX_SUBS_PER_SOCKET = 50; // cap a single socket's channel set
const TENANT_MSG_BUDGET = 200; // inbound messages per tenant per window
const RATE_WINDOW_MS = 10_000; // sliding window length for the budget

// Channel grammar: tenant:{id}:{topic}. ids/topics are [a-z0-9-] only,
// which blocks ":" injection that could forge a different prefix.
const CHANNEL_RE = /^tenant:([a-z0-9-]+):([a-z0-9-]+)$/;

interface Session {
tenantId: string; // set ONLY from the verified upgrade, never the message
subscriptions: Set<string>;
}

// Each socket carries its session; the tenantId here is the trust anchor.
const sessions = new WeakMap<WebSocket, Session>();

const sub = new Redis(); // dedicated connection: subscribe-mode is exclusive
const pub = new Redis(); // separate connection for publishing
const tenantMsgCount = new Map<string, number>();

// One Redis subscription per distinct channel, ref-counted across local sockets.
const localChannelRefs = new Map<string, number>();

function tenantBudgetOk(tenantId: string): boolean {
const used = tenantMsgCount.get(tenantId) ?? 0;
if (used >= TENANT_MSG_BUDGET) return false; // tenant exceeded its window budget
tenantMsgCount.set(tenantId, used + 1);
return true;
}
// Reset every tenant's budget on a fixed window tick (sliding-window-lite).
setInterval(() => tenantMsgCount.clear(), RATE_WINDOW_MS).unref();

// THE GUARD: returns the validated channel or throws with a close code.
function authorizeSubscription(session: Session, channel: string): string {
const match = CHANNEL_RE.exec(channel);
if (!match) throw { code: 4400, reason: "malformed channel" }; // grammar violation
const [, tenantId] = match;
// The single line that prevents cross-tenant leakage:
if (tenantId !== session.tenantId) {
throw { code: 4403, reason: "cross-tenant subscribe denied" }; // prefix != my tenant
}
if (session.subscriptions.size >= MAX_SUBS_PER_SOCKET) {
throw { code: 4429, reason: "subscription limit" }; // fan-out abuse cap
}
return channel;
}

function joinChannel(socket: WebSocket, session: Session, channel: string) {
session.subscriptions.add(channel);
const refs = localChannelRefs.get(channel) ?? 0;
if (refs === 0) sub.subscribe(channel); // first local subscriber: open Redis sub
localChannelRefs.set(channel, refs + 1);
}

// Redis delivers a message for an exact channel name; route it to local sockets
// whose session is subscribed to THAT channel — never a broader pattern.
sub.on("message", (channel: string, payload: string) => {
for (const client of wss.clients) {
const s = sessions.get(client);
if (s?.subscriptions.has(channel) && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
}
});

const wss = new WebSocketServer({ noServer: true });

wss.on("connection", (socket, req) => {
// tenantId comes from the upgrade-time auth, attached to req by the auth middleware.
const tenantId = (req as any).tenantId as string;
const session: Session = { tenantId, subscriptions: new Set() };
sessions.set(socket, session);

socket.on("message", (raw) => {
let msg: { action?: string; channel?: string };
try { msg = JSON.parse(raw.toString()); }
catch { return socket.close(4400, "bad json"); }

if (!tenantBudgetOk(session.tenantId)) {
return socket.close(4429, "tenant rate limit"); // protect noisy-neighbor blast radius
}

if (msg.action === "subscribe" && msg.channel) {
try {
const channel = authorizeSubscription(session, msg.channel);
joinChannel(socket, session, channel);
socket.send(JSON.stringify({ ok: true, channel }));
} catch (e: any) {
socket.send(JSON.stringify({ error: e.reason, code: e.code }));
}
}

if (msg.action === "publish" && msg.channel) {
// Publishers are guarded by the SAME prefix check — no publishing into other tenants.
try {
const channel = authorizeSubscription(session, msg.channel);
pub.publish(channel, raw.toString()); // fan-out is keyed by the tenant-scoped name
} catch (e: any) {
socket.send(JSON.stringify({ error: e.reason, code: e.code }));
}
}
});

socket.on("close", () => {
for (const channel of session.subscriptions) {
const refs = (localChannelRefs.get(channel) ?? 1) - 1;
if (refs <= 0) { localChannelRefs.delete(channel); sub.unsubscribe(channel); }
else localChannelRefs.set(channel, refs); // last local sub leaves: drop Redis sub
}
sessions.delete(socket);
});
});

The defensive core is small: tenantId !== session.tenantId rejects the forged prefix, the channel regex blocks :-injection that would otherwise let tenant:acme:x:globex:y masquerade as another tenant, and sub.subscribe(channel) only ever subscribes to fully-qualified, validated names — never a tenant:* pattern that would re-introduce the leak server-side. Because publish runs through the same authorizeSubscription guard, a compromised tenant cannot inject events into another tenant’s topic either.

Operational checklist #

  • Confirm tenantId is read only from the verified upgrade context, never from any client message field — grep for every assignment to session.tenantId
  • Add a negative test: a socket authenticated as tenant A subscribes to tenant:B:* and asserts a 4403
  • Verify the Redis subscriber uses exact channel names, not psubscribe tenant:*
  • Load-test one tenant to its TENANT_MSG_BUDGET
  • Emit a metric/log on every 4403
  • Check ref-counted unsubscribe on disconnect: after the last local subscriber leaves, sub.unsubscribe fires and localChannelRefs

FAQ #

Why namespace channels instead of using one Redis database per tenant? #

A database-per-tenant approach doesn’t scale on a shared fleet — Redis logical databases are limited and don’t shard, and your WebSocket nodes would need per-tenant connections. Prefix namespacing keeps a single connection pool and lets the same fan-out code serve thousands of tenants; isolation comes from the guard, not from physical separation.

Can a client bypass the guard by sending a publish with a forged channel? #

No, as long as both subscribe and publish run through authorizeSubscription. The guard compares the channel’s tenant segment to the session’s tenant, which was fixed at upgrade time. A forged channel field is rejected with 4403 before it ever reaches pub.publish. The session tenant is the only trusted source.

Why not use psubscribe tenant:* and filter in the handler? #

Pattern subscriptions pull every tenant’s traffic onto every node, then rely on application code to discard most of it — that’s both a leakage risk (one missing filter leaks everything) and a throughput tax. Subscribing to exact, validated channel names means Redis only delivers what a node actually has local subscribers for.

Does this work behind AWS ALB or sticky sessions? #

Yes. Namespacing is independent of load balancing — sticky sessions affect which node a socket lands on, not which channels it may name. The Redis fan-out makes the tenant boundary node-independent: a tenant’s subscribers can be spread across nodes and still receive only their own events.

How do per-tenant rate limits interact with per-connection limits? #

They’re layered. MAX_SUBS_PER_SOCKET caps a single abusive socket’s channel set; TENANT_MSG_BUDGET caps aggregate inbound message rate across all of a tenant’s sockets. Keep both — the per-socket cap stops one connection from exhausting fan-out, while the per-tenant budget stops a tenant from opening many sockets to evade it.

Back to Server-Side WebSocket Routing Patterns.