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 #

Namespacing is a tenancy boundary, so it is worth drawing where the check has to sit relative to everything else the message passes through.

The order a subscribe request must be checked in A subscribe request is validated for shape, its namespace prefix parsed, matched against the connection's own tenant, authorised against the user's scopes, and only then applied. The order a subscribe request must be checked in Schema validation the channel name is a well-formed string cheap Namespace parse tenant prefix extracted, never trusted as sent Tenant match prefix must equal the connection's own tenant the boundary Authorisation does this user hold the scope for this channel Subscribe only now does the socket join anything effect The tenant match must compare against the connection's identity, never against a value taken from the message
The third band is the tenancy boundary. Everything above it is hygiene; everything below assumes it held.
Namespacing schemes and what each costs A comparison of multi-tenant namespacing schemes across the isolation they provide, their fan-out cost, and when each is appropriate. Namespacing schemes and what each costs Isolation Fan-out cost Verdict Prefixed channel logical one bus default Channel per tenant logical one subscribe per tenant fine at low tenant count Redis DB per tenant stronger connection per tenant rarely worth it Cluster per tenant physical full stack each regulated workloads only A prefixed channel with a server-side tenant check gives logical isolation at no extra infrastructure cost
Stronger isolation is available and expensive. Most products need the first row done rigorously, not the fourth.

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.