Per-channel authorization for WebSocket subscriptions #
A user edits a WebSocket frame in DevTools, changes {"type":"subscribe","channel":"org:41:invoices"} to org:42:invoices, and starts receiving another customer’s invoices in real time. The connection was authenticated — the server knew exactly who the user was — but nothing checked whether that user was allowed to see that channel. Authentication at the upgrade answers “who is this?”; it does not answer “may they do this?”, and on a WebSocket, where one connection carries many subscriptions over hours, the second question must be asked for every subscribe, every publish, and again whenever permissions change.
Root cause #
HTTP APIs usually get authorization almost for free: each request passes through middleware that checks the route against the user’s permissions. A WebSocket server receives a stream of messages on one already-authenticated connection, and routing happens inside your message handler, where no framework middleware runs. Unless you deliberately put a policy check in front of every channel operation, the only check is the one at connect time, and that check knows nothing about which channels will be requested.
The second gap is time. HTTP authorization is evaluated per request, so revoking a user’s access to a project affects their very next request. A subscription is a standing grant: once the server adds a socket to project:88, every message published there flows to it until something removes it. Removing a user from a project in your admin UI does nothing to their open subscription unless you connect the two.
Resolution #
Model channel names as structured resources, parse them, and run every subscribe and publish through a single authorize function before touching the channel registry. Cache positive decisions briefly per connection to avoid a database hit on every message, and index subscriptions by user and resource so a permission change can find and revoke them.
import { WebSocket } from 'ws';
type Action = 'subscribe' | 'publish';
type Resource = { kind: 'org' | 'project' | 'user'; id: string; topic: string };
const CHANNEL_RE = /^(org|project|user):([A-Za-z0-9_-]{1,64}):([a-z_]{1,32})$/;
const DECISION_TTL_MS = 60_000; // cached allows expire; denies are never cached
declare function checkPermission(userId: string, action: Action, r: Resource): Promise<boolean>;
interface Conn { ws: WebSocket; userId: string; decisions: Map<string, number>; subs: Set<string> }
const subscribersByChannel = new Map<string, Set<Conn>>();
function parseChannel(name: string): Resource | null {
const m = CHANNEL_RE.exec(name); // reject anything outside the grammar outright
return m ? { kind: m[1] as Resource['kind'], id: m[2], topic: m[3] } : null;
}
async function authorize(c: Conn, action: Action, channel: string): Promise<boolean> {
const resource = parseChannel(channel);
if (!resource) return false;
// A user may always use their own private channel; everything else asks the policy.
if (resource.kind === 'user') return resource.id === c.userId;
const key = `${action}:${channel}`;
const cachedUntil = c.decisions.get(key);
if (cachedUntil && cachedUntil > Date.now()) return true;
const ok = await checkPermission(c.userId, action, resource);
if (ok) c.decisions.set(key, Date.now() + DECISION_TTL_MS);
return ok;
}
export async function onSubscribe(c: Conn, channel: string, reqId: string) {
if (!(await authorize(c, 'subscribe', channel))) {
c.ws.send(JSON.stringify({ type: 'error', reqId, code: 'forbidden', channel }));
return;
}
c.subs.add(channel);
const set = subscribersByChannel.get(channel) ?? new Set();
set.add(c);
subscribersByChannel.set(channel, set);
c.ws.send(JSON.stringify({ type: 'subscribed', reqId, channel }));
}
export async function onPublish(c: Conn, channel: string, payload: unknown, reqId: string) {
// Publishing is a separate permission: many readers, few writers.
if (!(await authorize(c, 'publish', channel))) {
c.ws.send(JSON.stringify({ type: 'error', reqId, code: 'forbidden', channel }));
return;
}
broadcast(channel, payload);
}
// Called from the permission service's change feed (via pub/sub, on every node).
export function revoke(userId: string, channelPrefix: string) {
for (const [channel, set] of subscribersByChannel) {
if (!channel.startsWith(channelPrefix)) continue;
for (const c of set) {
if (c.userId !== userId) continue;
set.delete(c);
c.subs.delete(channel);
for (const k of c.decisions.keys()) if (k.endsWith(channel)) c.decisions.delete(k);
c.ws.send(JSON.stringify({ type: 'unsubscribed', channel, reason: 'access_revoked' }));
}
}
}
declare function broadcast(channel: string, payload: unknown): void;
Three design choices carry the security. The channel grammar is strict, so a client cannot smuggle wildcards, path traversal or unexpected characters into a name that later reaches Redis or a database. Denials are never cached, so a user granted access mid-session gets it on their next attempt. And revocation removes the subscription and the cached decision, so a revoked user cannot resubscribe from cache. The channel naming conventions themselves are discussed in multi-tenant WebSocket channel namespacing.
The cache TTL is the maximum time a stale allow can survive if a revocation event is lost. Keep it short — a minute is typical — and treat the revocation feed as the primary mechanism with the TTL as the safety net.
Verification #
Write the tenant-isolation test that the DevTools attack describes, and run it in CI:
// Vitest: a user in org 41 must not be able to subscribe to org 42.
it('rejects cross-tenant subscriptions', async () => {
const ws = await connectAs('user-in-org-41');
ws.send(JSON.stringify({ type: 'subscribe', channel: 'org:42:invoices', reqId: 'r1' }));
const reply = await nextMessage(ws);
expect(reply).toMatchObject({ type: 'error', reqId: 'r1', code: 'forbidden' });
});
Add a second test for revocation: subscribe, remove the user from the organization through the admin API, and assert that an unsubscribed message arrives and that subsequent publishes to the channel are not delivered. In production, count forbidden responses by user; a spike from one account is someone probing channel names.
Operational checklist #
FAQ #
Isn’t authenticating the connection enough? #
No. Authentication identifies the user once; authorization decides what that user can do with each channel. One authenticated connection can request any channel name the client can type, so each request must be checked.
How expensive is a permission check per subscribe? #
Subscribes are rare compared with messages, so one database or policy-engine call per subscribe is usually fine. Publishes can be frequent, which is why the example caches positive decisions for a short time.
What should the client do when access is revoked? #
Remove the channel’s data from its UI or mark it as unavailable, and not resubscribe automatically. An unsubscribed message with a reason lets the client distinguish revocation from a normal unsubscribe.
Should authorization live in the WebSocket server or a separate service? #
Keep the decision in the same policy engine your HTTP API uses, so rules are defined once, and keep the enforcement in the WebSocket server, next to the channel registry. Duplicated rules drift apart and create exactly the gap this page closes.
Related #
- Validating JWT on the WebSocket Upgrade — the authentication step this builds on.
- Multi-Tenant WebSocket Channel Namespacing — channel names that make checks simple.
- WebSocket Rooms and Channel Subscriptions — the registry these checks guard.
- Validating WebSocket Messages with Zod — rejecting malformed requests before authorization.