How to check websocket origin validation

Send the handshake with a hostile origin: curl -v -H 'Origin: https://evil.example' ws://host/. A server that validates the header answers 403 Forbidden and no socket opens. One that answers 101 Switching Protocols accepts a socket from any page on the web, with the user's cookies attached to it.

Why check this

Run this before any release that adds a socket endpoint, and on every endpoint that reads a session cookie. The failure it prevents is cross-site WebSocket hijacking: a page on an unrelated domain opens a socket to your server, the browser attaches the session cookie to the handshake, and the attacker reads the live feed of a logged-in user.

The reason this check exists at all is that the same-origin policy does not apply to WebSockets. A browser opens a socket to any origin it is asked to, sends the cookies for the target host, and performs no preflight, so nothing on the browser side is deciding anything. The Origin header is advisory. The server is the only thing that can enforce it.

Prerequisites

origin-server.mjs, the target, on port 9314:

import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const ALLOWED = 'http://127.0.0.1:9313';
const enforce = process.env.ORIGIN_CHECK === 'on';

const server = createServer((req, res) => {
  res.writeHead(200, { 'set-cookie': 'sid=s3cr3t; Path=/', 'content-type': 'text/plain' });
  res.end('cookie set\n');
});
const wss = new WebSocketServer({
  server,
  verifyClient: ({ origin, req }, done) => {
    console.log(`upgrade origin=${origin ?? '(none)'} cookie=${req.headers.cookie ?? '(none)'}`);
    if (enforce && origin !== ALLOWED) return done(false, 403, 'Forbidden');
    done(true);
  },
});
wss.on('connection', (ws) => ws.send('welcome'));
server.listen(9314, '127.0.0.1', () =>
  console.log(`ws://127.0.0.1:9314 origin check ${enforce ? 'on' : 'off'}, allowed ${ALLOWED}`)
);

page-server.mjs, a separate origin on port 9313, whose page opens a socket and a fetch to the other origin:

import { createServer } from 'node:http';

const page = `<!doctype html><meta charset=utf-8><title>other origin</title><script>
const s = new WebSocket('ws://127.0.0.1:9314/');
s.onopen = () => console.log('websocket open from ' + location.origin);
s.onmessage = (e) => console.log('websocket message ' + e.data);
s.onerror = () => console.log('websocket error');
fetch('http://127.0.0.1:9314/', { credentials: 'include' })
  .then(() => console.log('fetch ok'))
  .catch((e) => console.log('fetch failed ' + e.message));
</script>`;
createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'text/html' });
  res.end(page);
}).listen(9313, '127.0.0.1', () => console.log('http://127.0.0.1:9313'));

origin-client.mjs, a non-browser client that sets whatever origin you give it:

import WebSocket from 'ws';
const ws = new WebSocket('ws://127.0.0.1:9314/', { origin: process.argv[2] });
ws.on('open', () => console.log('open, Origin sent: ' + process.argv[2]));
ws.on('message', (m) => { console.log('message ' + m); ws.close(1000); });
ws.on('error', (e) => console.log('error ' + e.message));
ws.on('close', (c) => console.log('close ' + c));

origin-browser.mjs, which loads the cookie first and then the page on the other origin. The page URL is an argument, and evil.test resolves to the loopback address inside this Chrome only:

import { open } from '../scripts/browser/session.mjs';
const page = process.argv[2] ?? 'http://127.0.0.1:9313/';
const s = await open({ args: ['--host-resolver-rules=MAP evil.test 127.0.0.1'] });
try {
  await s.goto('http://127.0.0.1:9314/');
  s.console.length = 0;
  await s.goto(page, { waitUntil: 'load' });
  await new Promise((r) => setTimeout(r, 2500));
  for (const m of s.console) console.log(m);
} finally { await s.close(); }

Steps

  1. Step 1.

    Start the server with no origin check (node origin-server.mjs) and send a handshake claiming a hostile origin.

    curl -sv --max-time 3 -H "Origin: https://evil.example" ws://127.0.0.1:9314/ 2>&1 | grep -E "^> (GET|Origin|Upgrade)|^< " | head -8
    
    > GET / HTTP/1.1
    > Upgrade: websocket
    > Origin: https://evil.example
    < HTTP/1.1 101 Switching Protocols
    < Upgrade: websocket
    < Connection: Upgrade
    < Sec-WebSocket-Accept: laVctbYnb8NOthYBXOUqcAstBnU=

    The upgrade is granted to an origin that does not exist. This is the unvalidated state.

  2. Step 2.

    With the check still off, drive Chrome from the page on port 9313, which opens both a fetch and a socket to port 9314.

    node origin-browser.mjs
    
    error: Access to fetch at 'http://127.0.0.1:9314/' from origin 'http://127.0.0.1:9313' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
    error: Failed to load resource: net::ERR_FAILED
    log: fetch failed Failed to fetch
    log: websocket open from http://127.0.0.1:9313
    log: websocket message welcome

    The server console for the same run:

    ws://127.0.0.1:9314 origin check off, allowed http://127.0.0.1:9313
    upgrade origin=https://evil.example cookie=(none)
    upgrade origin=http://127.0.0.1:9313 cookie=sid=s3cr3t

    One page, two calls, the same pair of origins. The fetch is blocked and the socket opens carrying sid=s3cr3t. There is no preflight line in between, because the browser sends none for a socket.

  3. Step 3.

    Stop the server, start it with ORIGIN_CHECK=on node origin-server.mjs, and repeat step 1.

    curl -sv --max-time 3 -H "Origin: https://evil.example" ws://127.0.0.1:9314/ 2>&1 | grep -E "^> (GET|Origin|Upgrade)|^< " | head -8
    
    > GET / HTTP/1.1
    > Upgrade: websocket
    > Origin: https://evil.example
    < HTTP/1.1 403 Forbidden
    < Connection: close
    < Content-Type: text/html
    < Content-Length: 9

    The rejection is an ordinary HTTP response, sent before any frame exists. curl ends with exit code 22.

  4. Step 4.

    With the check still on, send the same handshake claiming the allowed origin.

    node origin-client.mjs http://127.0.0.1:9313
    
    open, Origin sent: http://127.0.0.1:9313
    message welcome
    close 1000

    Nothing on the wire distinguishes this from the browser in step 2. The header is a string the client chose.

  5. Step 5.

    Serve the same page under a name that is not on the list and read what the browser reports.

    node origin-browser.mjs http://evil.test:9313/
    
    error: WebSocket connection to 'ws://127.0.0.1:9314/' failed: Error during WebSocket handshake: Unexpected response code: 403
    log: websocket error

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 101 Switching Protocols for https://evil.example | No origin check on this endpoint | Add one at the upgrade, before the socket exists. | | 403 Forbidden for the hostile origin, 101 for the real one | The allowlist works | Confirm it covers every socket path, not the one you tested. | | cookie=sid=... on a handshake from another origin | The browser attached the session to a cross-origin socket | Treat the endpoint as reachable by any page until the origin is checked. | | origin=(none) | The client is not a browser, or an intermediary stripped it | Decide deliberately. Accepting an absent origin admits every script on the internet. | | Unexpected response code: 403 in the browser console | The server rejected the handshake | Read the server log. The browser never sees the reason. | | Rejection arrives as close code 1006 in client code | The handshake failed, not the socket | 1006 carries no status. Read the HTTP response with curl. |

Common mistakes

Sign: A CORS review passes and the socket endpoint is still open to every site.Cause: CORS governs fetch and XHR. The WebSocket handshake sends no preflight and asks for no Access-Control-Allow-Origin, so a server with a correct CORS configuration and no verifyClient accepts cross-origin sockets. Step 2 shows both calls from one page, one blocked and one accepted.
Sign: The origin check is in place and a scripted client still gets a socket.Cause: Origin is a request header like any other. curl sets it with -H and the ws package sets it with an option. The check stops a browser page on the wrong domain, which is its whole purpose, and stops nothing else. Authentication is a separate control.
Sign: The endpoint accepts connections with no Origin header at all.Cause: A missing header is common for non-browser clients, so a check written as a deny-list of bad origins lets it through. A hostile page cannot suppress the header, so refusing an absent origin costs nothing on the browser path and closes the scripted one.
Sign: The allowlist compares hostnames and admits an unexpected site.Cause: Origin is a full origin: scheme, host and port. Comparing only the host lets http://app.example in when the list meant https://app.example, and a suffix match on 'app.example' also matches 'notapp.example'. Compare the whole string.

What to check next

FAQ

What is the WebSocket Origin header?

The browser sets it on the handshake to the origin of the page that called new WebSocket(). It is the only signal the server gets about who opened the socket, and a client outside a browser sets it to anything, as step 4 shows.

Does CORS apply to WebSockets?

No. There is no preflight and no Access-Control-Allow-Origin on a handshake. Step 2 runs a blocked fetch and an accepted socket from one page to one server, which is the clearest form of the answer.

Is an origin check enough to stop cross-site hijacking?

It is necessary and not sufficient. It closes the browser path, which is the one an attacker's page can use against a logged-in victim. A token checked at the upgrade closes the scripted path.

What should the server do when Origin is missing?

Refuse, unless the endpoint is meant for non-browser clients, and then require a token instead. A page in a browser cannot omit the header, so nothing legitimate is lost on that path.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2ws 8.21.3Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny