MRPH·LAB

TX·04 · NODE.JS / REALTIME · LOGGED 2026·08 · 12 MIN

WebSocket security in Node: CORS was never protecting you

Digital Descent runs on Socket.IO, which means I've spent months learning how much of what I knew about securing HTTP quietly stops applying the moment a connection upgrades. The short version: the browser was doing less for you than you thought, and your cors config was doing almost nothing. Here's what actually gates a socket, with the code, tested against my own backend.

Where the browser stops helping

A WebSocket starts life as an ordinary HTTP GET with an Upgrade: websocket header, and that's the last ordinary thing about it. The same-origin policy you've been leaning on restricts a page's ability to read cross-origin responses. It does not stop a page from opening a socket to your server. Any page on any origin can attempt the handshake, the browser attaches your cookies to it like any other credentialed request, there is no preflight, and once the connection is up, the page's JavaScript gets full two-way access to the frames. No Access-Control-Allow-Origin gate ever enters the picture.

The spec is honest about this. RFC 6455 hands enforcement entirely to the server, and states plainly that a server that skips the check "will accept connections from anywhere." The browser sends an Origin header on the upgrade, but it's a report, not a rule. Nobody enforces it unless you do.

Which brings us to the most copy-pasted line in every realtime tutorial: the cors option on the Socket.IO server. Socket.IO connects with HTTP long-polling first and upgrades to WebSocket after, and the cors option exists to put access-control headers on those polling XHR requests. That's the whole job. Socket.IO's own docs are blunt about it: CORS applies to the long-polling transport only, and WebSocket connections aren't subject to CORS at all. The one config line everyone points to when asked "is it secured" governs half of one transport, and not the one you care about.

The attack that falls out of this

Put those facts together and you get cross-site WebSocket hijacking, which has been documented since 2013 and still ships in real products. The recipe: your server authenticates the socket with a session cookie and doesn't check Origin. A logged-in user visits an attacker's page. That page opens a WebSocket to your server, the browser helpfully attaches the victim's cookie, your server accepts, and now the attacker's JavaScript is having a full conversation with your backend as the victim. It's CSRF on the handshake, except worse: classic CSRF is a blind write-only attack, and this one reads every response.

Browsers defaulting cookies to SameSite=Lax hemmed this in without killing it. SameSite draws its boundary at the site level, the registrable domain, not the origin. A compromised or attacker-controlled sibling subdomain is still same-site, so its cookies flow. Cookies get switched to SameSite=None for some unrelated embed integration and nobody remembers the socket. MeshCentral took a CVE for exactly this class of bug in 2024, Dozzle took one this year, and the researchers who took a fresh look at the attack in 2025 landed on a boring but correct conclusion: a server-side origin check is still the primary defense, and browser mitigations are a bonus layer you don't get to rely on.

Origin checks that actually check

In raw ws, the tempting place to do this is the verifyClient option, and the docs themselves tell you not to use it (it's marked discouraged, with a pointer to the issue explaining why). The maintained pattern is to handle the HTTP upgrade yourself, which also gives you a clean place to run async auth before a socket object ever exists:

server.js
import { createServer } from "http";
import { WebSocketServer } from "ws";

const ALLOW = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

const server = createServer();
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });

server.on("upgrade", (req, socket, head) => {
  socket.on("error", (err) => console.error(err));

  // exact match on the full origin string, nothing fuzzy
  if (!ALLOW.has(req.headers.origin)) {
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    return socket.destroy();
  }

  authenticate(req, (err, user) => {
    if (err || !user) {
      socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
      return socket.destroy();
    }
    wss.handleUpgrade(req, socket, head, (ws) =>
      wss.emit("connection", ws, req, user)
    );
  });
});

server.listen(8080);

On Socket.IO, the equivalent gate is allowRequest, which runs for every handshake on every transport. Note how it sits next to cors doing a different job:

io.js
const ALLOW = new Set(["https://app.example.com"]);

const io = new Server(httpServer, {
  // the actual gate: accepts or rejects every handshake
  allowRequest: (req, cb) => cb(null, ALLOW.has(req.headers.origin)),
  // the browser plumbing: only matters for the polling XHR
  cors: { origin: [...ALLOW], credentials: true },
});

The comparison has to be an exact match on the full origin string, scheme and host and port, against a set. Every fuzzy version of this check is a real bug I've seen in the wild: startsWith("https://app.example.com") happily matches https://app.example.com.evil.com, and includes("example.com") is worse. Never allowlist the literal string null, because sandboxed iframes and local files send Origin: null and you've just allowlisted every one of them. And decide on purpose what happens when the header is missing entirely: non-browser clients don't send one, so for a browser-facing service, missing means reject.

Authentication that outlives the handshake

The browser's WebSocket constructor can't set headers, which is how tokens ended up in query strings in the first place, and query strings are the wrong place for a credential. URLs get written to access logs, proxy and CDN logs, browser history, and whatever APM tool is sampling your traffic. Every one of those becomes a place your tokens live now. Socket.IO gives you a proper lane instead, the handshake auth payload, checked in middleware before your handlers ever see the connection:

auth.js
// client side: the token rides the handshake, not the URL
const socket = io("https://app.example.com", { auth: { token } });

// server side: runs once per connection attempt
io.use((socket, next) => {
  try {
    socket.data.user = verifyJwt(socket.handshake.auth.token);
    next();
  } catch {
    next(new Error("not authorized")); // client sees connect_error
  }
});

For raw ws the answer depends on the client. Anything that isn't a browser (a mobile app, a service, the ws client itself) can send a normal Authorization header on the upgrade, so do that. For browsers, the workable pattern is connect first and authenticate with the first message, with two rules attached: the socket does absolutely nothing else until that auth frame arrives, and a timer kills any connection that hasn't authenticated within a few seconds. Unauthenticated sockets holding file descriptors are a resource you're lending to strangers.

Then there's the part almost everyone skips: a WebSocket validated at connect time stays trusted for the life of the connection, and these connections live for hours. Revoke a token, ban an account, and the socket keeps humming along on yesterday's decision. So make expiry an event rather than a fact you check once:

expiry.js
function armExpiry(conn, claims) {
  const ttl = claims.exp * 1000 - Date.now();

  // 1008 is the "policy violation" close code
  const hardStop = setTimeout(
    () => conn.close(1008, "token expired"),
    Math.max(0, ttl)
  );
  const recheck = setInterval(() => {
    if (isRevoked(claims.jti)) conn.close(1008, "revoked");
  }, 60_000);

  conn.on("close", () => {
    clearTimeout(hardStop);
    clearInterval(recheck);
  });
}

"In the room" is not "allowed in the room"

Socket.IO's rooms are a routing mechanism, and it is very easy to start treating them as an authorization system. They aren't one. socket.rooms records that a join happened at some point in the past. It has no opinion on whether the user is still entitled to be there. Permissions change mid-connection: people get demoted, banned, and removed from documents, and the socket they opened an hour ago doesn't know any of it happened.

The payload makes it worse, because the client picks the IDs. A handler for doc:update receives whatever docId the client felt like sending, and if you only verify "is this socket in a room," you've built the WebSocket version of an IDOR. The fix is the same as it is over HTTP: check the actual entitlement, against your actual policy source, on every request. Socket.IO gives you a per-packet middleware for exactly this, so the check lives in one place instead of being copy-pasted into forty handlers:

authz.js
io.on("connection", (socket) => {
  // runs for every incoming packet on this socket
  socket.use(async ([event, payload], next) => {
    const ok = await can(socket.data.user, event, payload?.id);
    ok ? next() : next(new Error("forbidden"));
  });
});

Yes, that's a policy lookup per message. If the datastore can't wear that, cache positive decisions for a few seconds and invalidate on the same ban/logout events that revoke tokens. The cache TTL is your revocation delay, so pick the number deliberately instead of discovering it during an incident.

Events are request bodies your WAF never sees

Everything I wrote in TX·01 about validating input at the door applies here, with one extra wrinkle: most WAFs and API gateways inspect the HTTP upgrade and then go blind, because the frames that follow never pass through their HTTP parsing. Whatever inspection you think your edge is doing, your socket events aren't getting it. The schema has to live in the app, keyed by event name, with unknown events rejected outright:

schema.js
import { z } from "zod";

const registry = {
  "doc:update": z.object({
    docId: z.string().uuid(),
    patch: z.string().max(10_000),
  }),
  "chat:send": z.object({
    room: z.string().max(64),
    text: z.string().max(2_000),
  }),
};

io.on("connection", (socket) => {
  socket.use(([event, payload], next) => {
    const schema = registry[event];
    if (!schema) return next(new Error("unknown event"));
    if (!schema.safeParse(payload).success) return next(new Error("bad payload"));
    next();
  });
});

Two things worth internalizing about this layer. First, the parsing code underneath your handlers has had real vulnerabilities: crafted event names that crash the process, prototype pollution smuggled through the parser's placeholder mechanism. A schema won't save you from a parser bug (see the patching section), but it shuts down every payload-shaped attack above the parser, including the deep-merge tricks where someone ships __proto__ in a payload you were about to merge into a server object. Second, remember the output side: a stored chat message with an onerror handler in it, rendered later without encoding, is stored XSS that arrived over a channel no scanner crawled. Encode on render, same as always.

The DoS surface nobody budgets for

A WebSocket is a long-lived TCP connection holding a file descriptor and buffers on your box, which makes it a fundamentally different resource from a request that's over in 80 milliseconds. Start with the numbers, because one of them is genuinely surprising: ws caps message size at 100 MiB by default. Socket.IO's maxHttpBufferSize defaults to a saner 1 MB, and neither number describes any message a real app sends. Set them to your actual maximum:

limits.js
// nobody's chat message is 100 MiB
const wss = new WebSocketServer({
  maxPayload: 64 * 1024,
  handshakeTimeout: 5000,   // slow-loris upgrades don't get to linger
});

const io = new Server(httpServer, { maxHttpBufferSize: 64 * 1024 });

The subtler leak is outbound. When a client reads slower than you write, the difference piles up in the socket's send buffer, per connection, in your process memory. A few hundred deliberately slow consumers on a chatty broadcast server add up to an out-of-memory kill with no attack traffic that looks like attack traffic. ws exposes bufferedAmount so you can notice and act:

backpressure.js
const HIGH_WATER = 1024 * 1024; // 1 MB queued means they're not keeping up

function safeSend(ws, data) {
  if (ws.bufferedAmount > HIGH_WATER) return ws.terminate();
  ws.send(data);
}

Rate limiting has a placement problem specific to sockets: your load balancer sees one long-lived connection per client and has no idea five thousand frames a second are flowing inside it. Per-message limits have to live in the app. I use rate-limiter-flexible, which is actively maintained, fast, and backed by memory on one node or Redis when you scale out:

ratelimit.js
import { RateLimiterMemory } from "rate-limiter-flexible";

const perEvent = new RateLimiterMemory({ points: 20, duration: 1 });

io.on("connection", (socket) => {
  socket.use(async ([event], next) => {
    try {
      await perEvent.consume(`${socket.data.user.id}:${event}`);
      next();
    } catch {
      next(new Error("slow down"));
    }
  });
});

Key the limits (and your connection caps) per authenticated user, not just per IP. Per-IP caps false-positive on everyone behind carrier NAT and do nothing against an attacker with a hundred cheap addresses. Two last items for the list: leave permessage-deflate compression off, which is the server default in ws for good reason (the maintainers warn about the memory cost outright, and decompression is amplification an attacker controls), and give your clients jittered exponential backoff on reconnect, because the day you deploy, every connected client comes back at once and does a passable impression of a DDoS you launched at yourself.

Patch like it's part of the job

Everything above is design. This part is maintenance, and with WebSocket libraries it isn't optional, because the recent vulnerability history is not theoretical: a request with too many headers could crash ws outright before 8.17.1, a flood of tiny fragments could exhaust memory before 8.21.0, a crafted packet could throw an uncaught exception and kill the process through engine.io, and socket.io-parser has had both a crash-by-event-name and a prototype pollution issue. These are all fixed, which only helps if you're on the fixed versions: as I write this that means ws at 8.21.0 or later and Socket.IO at 4.8.3 or later, which pulls in the patched parser and engine underneath it. This paragraph will age; npm audit in CI is the part that doesn't.

The checklist

The whole article, compressed into the list I run against my own realtime services:

The theme running through all of it: a WebSocket buys its speed by stepping around HTTP's request/response machinery, and that machinery is where every defense you were relying on happened to live. None of the replacements are exotic. Everything here was tested on Node 24 LTS with current ws and Socket.IO releases, on the same bench where Digital Descent's backend is taking shape, which is exactly why I went down this hole in the first place.