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:
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:
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:
// 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:
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:
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:
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:
// 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:
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:
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:
- Origin allowlist on the upgrade: exact match, full origin string, in
server.on("upgrade")orallowRequest Origin: nulland missing Origin both rejected for browser-facing services- Credentials ride the handshake
authpayload or anAuthorizationheader, never the query string - Connect-first designs enforce an auth deadline; pre-auth sockets can do nothing else
- Hard close at token expiry plus periodic revocation recheck, close code 1008
- Authorization runs per event against the policy source, not the room set; payload IDs never trusted
- Schema per event name, unknown events rejected
maxPayload/maxHttpBufferSizeset to real message sizes, not the defaultspermessage-deflatestays off unless measured and justified- Per-event rate limits in the app; connection caps per user, not just per IP
bufferedAmountchecked before writes, slow consumers terminatedws≥ 8.21.0,socket.io≥ 4.8.3,npm auditwired into CI
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.