Enforcing Origin and CSRF checks on WebSockets #

Your WebSocket server authenticates with the session cookie, the handshake succeeds, and traffic looks clean — yet an attacker on evil.example is reading a logged-in user’s private stream. Nothing in your logs looks wrong because, from the server’s perspective, the request is a legitimately authenticated user. What you are seeing is Cross-Site WebSocket Hijacking (CSWSH): a malicious page opened an authenticated socket against your backend, and the browser obediently attached the victim’s cookies for it. If you landed here after a pentest flagged “missing Origin validation” or you noticed sockets opening from a referrer you do not recognize, this is the fix.

Root cause #

The WebSocket handshake is an HTTP request, but it is not governed by the same-origin policy the way fetch and XMLHttpRequest are. When a page calls new WebSocket('wss://api.example.com/ws'), the browser sends a normal GET with an Upgrade: websocket header — and it attaches every cookie scoped to api.example.com, regardless of which origin’s JavaScript initiated the call. There is no preflight, no CORS response-header check, and crucially no blocking on the response. With fetch, even a cross-origin request that the server processes is hidden from the calling script unless the server returns the right Access-Control-Allow-Origin header. WebSockets have no such gate: once the server replies 101 Switching Protocols, the attacker’s script holds a live, bidirectional, fully-authenticated channel.

This is why CORS does not protect you here. CORS is a response-reading restriction enforced by the browser on the client side; it never reaches the WebSocket upgrade path. The Access-Control-Allow-Origin header is meaningless on a 101 response. The only signal the server receives that distinguishes a same-site socket from a cross-site one is the Origin request header — which the browser sets honestly and the page’s JavaScript cannot forge. So the server, not the browser, must do the checking.

CSWSH attack vs Origin allowlist defense A malicious page opens an authenticated socket with the victim's cookies; the server's Origin allowlist check rejects it before the 101 upgrade. evil.example new WebSocket(...) + victim cookies Browser sends Upgrade GET Origin: evil.example ws server verifyClient checks Origin Origin not in allowlist Reject: 403, no 101 upgrade Socket never opens CORS never runs on the upgrade path

There is a sharp edge worth naming: the Origin header is reliable only because it comes from a browser. Non-browser clients — curl, a native mobile app, a server-to-server script — can send any Origin they like, or none at all. Origin validation therefore defends against the browser-driven CSWSH attack specifically; it is not an authentication mechanism. Pair it with real credential validation as described in WebSocket Authentication & Authorization, and decide deliberately how to treat a missing Origin (browsers always send it for cross-origin requests; same-origin requests may omit it on some browsers, and non-browser clients omit it entirely).

Resolution #

Validate Origin inside the ws server’s verifyClient hook so rejection happens before the 101 is written — a rejected handshake never becomes a socket. For cookie-based auth, layer a CSRF token on top: a value the attacker’s page cannot read (because it lives behind same-origin storage or a non-cookie response), passed on the upgrade and compared server-side. This double-submit pattern closes the gap that the cookie alone leaves open.

import { WebSocketServer } from 'ws';
import { IncomingMessage } from 'http';
import { timingSafeEqual } from 'crypto';

// Exact-match allowlist. Never substring-match (evil-example.com would slip past).
const ALLOWED_ORIGINS = new Set<string>([
'https://app.example.com',
'https://admin.example.com',
]);

// Constant-time compare so token validity can't be timed byte-by-byte.
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false; // length leak is acceptable here
return timingSafeEqual(ab, bb);
}

function checkOriginAndCsrf(req: IncomingMessage): boolean {
const origin = req.headers.origin; // browser sets this honestly
// Reject anything not on the allowlist. A missing Origin on a cross-origin
// request can't happen in a browser, so treat absent Origin as untrusted here.
if (!origin || !ALLOWED_ORIGINS.has(origin)) return false;

// Double-submit CSRF: the token rides in the query string (browsers can't set
// custom WS headers), the matching value lives in a cookie the attacker can't read.
const url = new URL(req.url ?? '/', 'http://localhost');
const sentToken = url.searchParams.get('csrf') ?? '';
const cookieToken = parseCookie(req.headers.cookie ?? '')['csrf'] ?? '';
if (!sentToken || !cookieToken) return false;

// Both halves must match. The cross-site page has the cookie sent for it
// automatically, but it cannot READ the cookie to echo it into the query string.
return safeEqual(sentToken, cookieToken);
}

const wss = new WebSocketServer({
port: 8080,
// verifyClient runs during the HTTP upgrade, before any 101 is emitted.
verifyClient: (info, done) => {
if (!checkOriginAndCsrf(info.req)) {
// done(false, code, message) -> server replies 403 and aborts the upgrade.
return done(false, 403, 'Forbidden origin or invalid CSRF token');
}
done(true); // allow: proceed to 101 Switching Protocols
},
});

function parseCookie(header: string): Record<string, string> {
return Object.fromEntries(
header.split(';').map((c) => {
const [k, ...v] = c.trim().split('=');
return [k, decodeURIComponent(v.join('='))];
}).filter(([k]) => k),
);
}

wss.on('connection', (socket, req) => {
// Identity is already validated at upgrade time; attach it for later authorization.
socket.send(JSON.stringify({ type: 'ready' }));
});

The double-submit token works because of an asymmetry: the browser sends the CSRF cookie automatically on the cross-site upgrade, but the attacker’s JavaScript cannot read that cookie (it is on a different origin) to copy it into the ?csrf= query parameter. If you set the CSRF cookie with SameSite=Strict or Lax, the browser will not even attach it to a genuinely cross-site WebSocket upgrade, which is a second, independent layer. Use both; do not rely on SameSite alone, because older browsers and certain redirect flows weaken it.

Operational checklist #

The reason this check exists at all is that the browser’s usual protections do not apply to the upgrade, and it is worth seeing the difference side by side.

What protects a fetch and what protects an upgrade A comparison showing that CORS never applies to a WebSocket upgrade and the response is fully readable cross-origin, which is why an explicit origin check is required. What protects a fetch and what protects an upgrade fetch() WebSocket upgrade CORS preflight yes never runs Origin header sent yes yes, by browsers Cookies attached per SameSite per SameSite Response readable cross-origin blocked by CORS fully readable Non-browser client no Origin no Origin The fourth row is the whole risk: a cross-origin page can open your socket and read everything on it
CORS does not apply here. The Origin header is sent, but nothing enforces it unless you do.
A cross-site upgrade, allowed and rejected A cross-origin browser upgrade carries an Origin header that the allow-list rejects, while a scripted client sends no Origin at all, so authentication rather than the origin check is what stops it. A cross-site upgrade, allowed and rejected evil.example Your server upgrade, Origin: https://evil.example origin not in allow-list 403, socket destroyed upgrade with no Origin (scripted client) origin check passes vacuously — auth must catch it An absent Origin must never be treated as trusted — it is the normal case for every non-browser client
The origin check protects browser users. Only authentication protects you from everyone else.

FAQ #

Why doesn’t CORS protect a WebSocket? #

CORS is a browser-enforced restriction on reading cross-origin HTTP responses; it never runs on the WebSocket upgrade. The browser sends the upgrade request (with cookies) regardless of origin, and the server’s 101 response is not subject to any Access-Control-Allow-Origin check. The server must inspect the Origin header itself. See handling cross-origin WebSocket connections for the broader cross-origin model.

Is checking the Origin header enough on its own? #

For browser-driven CSWSH, Origin validation is the core defense. But Origin is trustworthy only from browsers — curl and native clients can spoof it — so it is not a substitute for authenticating the user. Combine it with real credential checks from WebSocket Authentication & Authorization, and add the CSRF token whenever you authenticate with cookies.

Why a CSRF token if cookies are already sent automatically? #

That automatic sending is exactly the problem: the cross-site page gets the victim’s cookie attached for free. The CSRF token defeats this because the attacker’s script can send the cookie but cannot read it to echo a matching value into the query string. The server requires both halves to match, so the cross-site request fails.

Where should the CSRF token travel on the upgrade? #

The browser WebSocket constructor forbids custom headers, so the token rides in the query string (?csrf=...) or, less commonly, the Sec-WebSocket-Protocol value. The matching copy lives in a same-origin cookie. Avoid putting long-lived secrets in the URL where they can leak into logs — use a short-lived, per-session token.

Does this change behind a reverse proxy or load balancer? #

Yes — verify the proxy passes Origin and Cookie through unmodified. If nginx or an ALB strips or rewrites Origin, your verifyClient check sees nothing and either rejects everyone or, if you fail open, protects no one. Confirm header forwarding as part of your Backend WebSocket Connection Management setup.

What should I do when the Origin header is missing? #

Decide deliberately rather than defaulting. Native clients, server-to-server connections and command-line tools legitimately send no Origin, so rejecting all of them breaks real integrations — but treating absent as trusted means the check does nothing for the case it was written for. The workable rule is to require a valid Origin for cookie-authenticated connections and to require a bearer token for connections without one.

Does SameSite=Strict make the origin check unnecessary? #

It substantially reduces the risk, because a cross-site upgrade will not carry your cookie, but it does not eliminate it — same-site subdomains, older browsers and any non-cookie credential path all sit outside its protection. Keep both: SameSite is defence in depth for the credential, and the origin allow-list is an explicit statement of who may open a socket at all.

Back to WebSocket Authentication & Authorization