Blog/Web Security
Web SecurityAugust 2, 2026 · 6 min read

WebSocket Security: Why the Same-Origin Rules Don't Apply

WebSockets feel like a natural extension of HTTP, so it's easy to assume they inherit the same protections. They don't. Two differences matter for security:

  • CORS does not apply to the WebSocket handshake. Any origin can open a socket to your server. The cross-origin rules that block a rogue site from reading a normal fetch response simply aren't in play here.
  • Cookies are sent automatically. The handshake is an HTTP request, so the browser attaches your session cookie to it — even when the connection is initiated by a different site.

Put those together and you get cross-site WebSocket hijacking (CSWSH): a page on evil.com opens a WebSocket to your server, the browser includes the victim's cookies, and if your server authenticates from cookies alone without checking the Origin, the attacker's page now holds an authenticated, bidirectional channel to your app — reading the victim's real-time data and sending actions as them.

The threats specific to WebSockets

  • Cross-site hijacking (CSWSH) — the core issue above: authenticated connections established from a malicious origin.
  • Missing authorization per message. Apps often check auth once at connect and then trust every message. If a connection is hijacked, every message is trusted too.
  • Injection through messages. WebSocket frames are input like any other. Messages rendered into the DOM cause XSS; messages used in queries cause injection. The channel being persistent doesn't make its data trusted.
  • No transport security. Plain ws:// is cleartext — sniffable and tamperable. Always use wss://.

The fix: validate origin, authenticate explicitly, treat messages as untrusted

const { WebSocketServer } = require('ws');

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

const wss = new WebSocketServer({
// 1) VALIDATE ORIGIN during the handshake. CORS won't do this for you.
verifyClient(info, done) {
  const origin = info.req.headers.origin;
  if (!ALLOWED_ORIGINS.has(origin)) return done(false, 403, 'Bad origin');
  done(true);
},
});

wss.on('connection', (ws, req) => {
// 2) AUTHENTICATE EXPLICITLY — don't trust ambient cookies alone.
//    Require a short-lived token (issued over authenticated HTTPS),
//    passed as the first message or a query param, and verify it.
const token = new URL(req.url, 'https://x').searchParams.get('token');
const user = verifyWsToken(token);          // your signed, single-use token
if (!user) { ws.close(4001, 'Unauthorized'); return; }

ws.on('message', (raw) => {
  // 3) TREAT EVERY MESSAGE AS UNTRUSTED INPUT. Validate a schema and
  //    re-check authorization per action — a connection is not a
  //    blanket permission slip.
  let msg;
  try { msg = JSON.parse(raw); } catch { return ws.close(1003); }
  if (!isValid(msg) || !can(user, msg.action)) return; // authz per message
  handle(user, msg);
});
});

The principles

  • Validate the Origin header at the handshake. CORS won't; you must. Allowlist the origins permitted to connect and reject the rest.
  • Authenticate the connection explicitly. Don't rely on ambient cookies. Issue a short-lived, single-use token over an authenticated HTTPS request and verify it on connect — a cross-site page can't get the token.
  • Authorize every message, not just the connection. Validate a schema and re-check permissions per action, so a hijacked or confused connection can't do arbitrary things.
  • Use wss:// and render messages as data. Encrypt the transport, and treat every frame as untrusted input — textContent, not innerHTML; parameterized queries, not concatenation.

The one line: WebSockets opt out of CORS and carry cookies automatically, so you must check the origin, authenticate with a token, and distrust every message.

Try it in the WebSocket hijacking simulation: open a cross-origin socket that rides the victim's cookies, then add origin validation and token auth and watch the hijack fail. It pairs with the CORS misconfigurations guide — two sides of how browsers handle cross-origin access.

Share this post

Frequently Asked Questions

Related posts

Race Conditions in Web Apps: When Two Requests Beat Your Check

Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.

Aug 11, 2026 · 7 min read

Mass Assignment: How role=admin Ends Up in Your Update

Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.

Aug 10, 2026 · 6 min read

Insecure Deserialization: How Loading Data Becomes Running Code

Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.

Aug 9, 2026 · 7 min read