Cookie session authentication for WebSockets #

Your application already authenticates users with an HTTP-only session cookie, and you want the WebSocket to use the same session rather than inventing a token scheme. It mostly works — the browser does send cookies on the upgrade request — but then a security review flags cross-site WebSocket hijacking, the socket stays authenticated for hours after the user logs out, and users who embed your widget on a partner site cannot connect at all. Cookie-based WebSocket auth is a sound choice, but only when you handle the three things HTTP frameworks normally do for you: CSRF-style origin checks, session expiry, and cookie scoping rules.

Root cause #

The WebSocket handshake is an ordinary HTTP GET with an Upgrade header, and browsers attach cookies to it exactly as they would to any request to that host. That is convenient, and it is also the source of the main vulnerability. Unlike fetch and XMLHttpRequest, WebSocket connections are not subject to CORS: any page on any origin can call new WebSocket('wss://your-app.example/ws'), and the browser will attach your user’s cookies if their SameSite attribute allows it. If the server authenticates purely by cookie, an attacker’s page can open a fully authenticated socket and read the user’s real-time data. This is cross-site WebSocket hijacking (CSWSH).

The second problem is time. A cookie session is checked once, at the upgrade. The socket then lives for hours. HTTP requests re-validate the session on every request, so logout or session revocation takes effect immediately; a socket authenticated at 9 a.m. keeps its privileges until it disconnects, unless you do something about it.

Cross-site WebSocket hijacking A victim visits an attacker's page, which opens a WebSocket to the target application; the browser attaches the victim's cookies and the server streams private data to the attacker's page. Cross-site WebSocket hijacking Victim browser evil.example page your-app.example visits attacker page new WebSocket(wss://your-app) cookies attached automatically 101 — authenticated as victim private messages streamed The Origin header on that upgrade says evil.example — checking it is the fix
Cookie auth without an Origin check lets any website act as your user.

Resolution #

Authenticate on the upgrade by parsing the session cookie and loading the session, but only after verifying that the Origin header is on an allowlist. Browsers always send Origin on WebSocket handshakes and scripts cannot forge it, so it is a reliable CSRF defence for this case. Then keep the session’s expiry on the socket and enforce it: close the connection when the session expires, and subscribe to a revocation channel so logout closes sockets immediately.

import http from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { parse as parseCookie } from 'cookie';

const SESSION_COOKIE = 'sid';
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
const CLOSE_SESSION_EXPIRED = 4001;
const CLOSE_SESSION_REVOKED = 4003;

interface Session { id: string; userId: string; expiresAt: number }
declare function loadSession(id: string): Promise<Session | null>;
declare function onSessionRevoked(cb: (sessionId: string) => void): void;

const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
const socketsBySession = new Map<string, Set<WebSocket>>();

function reject(socket: import('node:stream').Duplex, status: number, text: string) {
socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}

server.on('upgrade', async (req, socket, head) => {
// 1. CSWSH defence: the browser-set Origin must be one of ours.
const origin = req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.has(origin)) return reject(socket, 403, 'Forbidden');

// 2. Resolve the session from the same cookie the HTTP app uses.
const sid = parseCookie(req.headers.cookie ?? '')[SESSION_COOKIE];
const session = sid ? await loadSession(sid) : null;
if (!session || session.expiresAt <= Date.now()) return reject(socket, 401, 'Unauthorized');

wss.handleUpgrade(req, socket, head, (ws) => {
// 3. Enforce expiry for the lifetime of the socket, not just at connect.
const ttl = session.expiresAt - Date.now();
const expiry = setTimeout(() => ws.close(CLOSE_SESSION_EXPIRED, 'session expired'), ttl);
const set = socketsBySession.get(session.id) ?? new Set();
set.add(ws);
socketsBySession.set(session.id, set);
ws.on('close', () => {
clearTimeout(expiry);
set.delete(ws);
if (set.size === 0) socketsBySession.delete(session.id);
});
wss.emit('connection', ws, req, session);
});
});

// 4. Logout or admin revocation closes every socket on that session, on every node
// (onSessionRevoked is backed by a pub/sub channel so all nodes hear it).
onSessionRevoked((sessionId) => {
for (const ws of socketsBySession.get(sessionId) ?? []) {
ws.close(CLOSE_SESSION_REVOKED, 'session revoked');
}
});

server.listen(8080);

If your sessions slide — each HTTP request extends them — the socket’s fixed expiry timer will close connections that the HTTP side considers alive. Either extend the session when socket activity occurs (and reset the timer), or have the client make a lightweight HTTP call that refreshes the session and then send a session_refreshed message so the server re-reads the expiry. Closing with a distinct code lets the client refresh and reconnect without looping, as described in avoiding reconnect loops on auth failure.

Cookie attributes decide whether the cookie is sent at all. SameSite=Lax and Strict cookies are sent on same-site WebSocket handshakes, which covers the normal case where your page and socket share a registrable domain (app.example.com and rt.example.com are same-site). A widget embedded on another site needs SameSite=None; Secure, and then the Origin allowlist becomes the only thing standing between that cookie and any site on the web.

Will the session cookie reach the upgrade? Whether a session cookie is attached to a WebSocket handshake for same-site pages, cross-site pages and browsers that block third-party cookies, by SameSite attribute. Will the session cookie reach the upgrade? Same site Cross site Third-party blocked SameSite=Strict sent not sent not sent SameSite=Lax sent not sent not sent SameSite=None; Secure sent sent — check Origin not sent Browsers that block third-party cookies make cross-site cookie auth unreliable — use a ticket there instead
Same-site deployments work with any SameSite value; cross-site embedding needs a different credential.

For the cross-site case, a short-lived ticket fetched over an authenticated fetch call and passed on the URL is more robust than cookies, because third-party cookie blocking is now widespread. That design is covered in authenticating WebSockets with short-lived tickets.

Verification #

Test the Origin check directly — this is the control a penetration tester will try first:

# Valid session, forged foreign origin: must be rejected with 403.
curl -si --http1.1 https://rt.example.com/ws \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Origin: https://evil.example' -H "Cookie: sid=$VALID_SID" | head -1
# Same request with Origin: https://app.example.com must return 101.

Then test the lifecycle: open a socket in the browser, log out in another tab, and confirm the socket closes with 4003 within a second. Finally, set a session with a two-minute lifetime and confirm the socket closes with 4001 at expiry without any user action.

A socket bound to its session A socket is authenticated at upgrade, its session is extended at forty-five minutes, and when the user logs out at ninety minutes the socket is closed with code 4003 immediately. A socket bound to its session authenticated socket upgrade: origin + cookie ok (0 min) user active, session extended (45 min) logout in another tab (90 min) socket closed 4003 (90.5 min) Without the revocation channel the socket would stay authenticated until it next disconnected
The socket's authority ends when the session's does.

Operational checklist #

FAQ #

Do browsers send cookies on WebSocket connections? #

Yes. The handshake is an HTTP request to the socket’s host, and cookies for that host are attached subject to their SameSite, Secure, Domain and Path attributes, just as for any other request.

Is checking the Origin header really enough to stop CSWSH? #

For browser clients, yes: browsers always set Origin on WebSocket handshakes and page scripts cannot override it. Non-browser clients can send any Origin they like, but they also do not have your user’s cookies, so they cannot hijack a session. Pair the Origin check with SameSite cookies for defence in depth — enforcing origin and CSRF checks on WebSockets covers the details.

Should I use cookies or JWTs for WebSocket auth? #

Use whatever your HTTP application already uses, so sessions have one source of truth. Cookies make revocation easy because the session lives server-side; JWTs avoid a store lookup but need a revocation list or short lifetimes. Both need an expiry check on the open socket.

Not if it is HttpOnly, which it should be. That is the point of letting the browser attach it to the handshake: the credential is never exposed to scripts.

Back to WebSocket Authentication & Authorization.