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.
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 #
-
verifyClientrejects every origin not inALLOWED_ORIGINSand the allowlist uses exact string equality, notincludes/startsWith - A handshake with no
Origin - CSRF cookie is set
Secure,HttpOnlyis off only if the SPA must read it; prefer a separate readable token and anHttpOnly - CSRF cookie carries
SameSite=LaxorStrict - Reverse proxy forwards
OriginandCookieheaders untouched to the Node process (a strippedOrigin - Token comparison uses
timingSafeEqual, never=== - A test opens a socket with a forged
Originand confirms it receives403, never a101
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.
Related #
- WebSocket Authentication & Authorization — the parent area covering cookie, ticket, and JWT auth on the upgrade.
- Handling Cross-Origin WebSocket Connections — the full cross-origin model and where Origin validation fits.
- Backend WebSocket Connection Management — lifecycle, routing, and proxy header forwarding fundamentals.