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
- Node 22 with
ws8 in a scratch directory:npm i ws@8. See verifyClient in the ws documentation. - curl 8.21 or later. Earlier builds have no
wsscheme and answer with a protocol error. - The four files below, in that scratch directory. The socket server reads
ORIGIN_CHECKso the same binary runs with the check off and on.
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
- 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.
- Step 2.
With the check still off, drive Chrome from the page on port 9313, which opens both a
fetchand a socket to port 9314.node origin-browser.mjserror: 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 welcomeThe 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=s3cr3tOne page, two calls, the same pair of origins. The
fetchis blocked and the socket opens carryingsid=s3cr3t. There is no preflight line in between, because the browser sends none for a socket. - 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: 9The rejection is an ordinary HTTP response, sent before any frame exists. curl ends with exit code 22.
- Step 4.
With the check still on, send the same handshake claiming the allowed origin.
node origin-client.mjs http://127.0.0.1:9313open, Origin sent: http://127.0.0.1:9313 message welcome close 1000Nothing on the wire distinguishes this from the browser in step 2. The header is a string the client chose.
- 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
What to check next
- How to check if websocket is secure: an origin check over plain
ws://is rewritten by anyone on the path. - Websocket authentication jwt: the control that stops the scripted client in step 4.
- How to test CSRF protection: the same cookie-attachment problem on ordinary form posts.
- How to check CORS configuration: the policy that covers
fetchand stops at the socket. - How to check cookie flags with curl:
SameSiteon the session cookie decides whether the handshake carries it across sites.
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.
Related on this site
intermediate10 minpublished updated Maks Verny