ASVS 5.0 V4.4.2 (level 2) requires that during the initial HTTP handshake of a WebSocket connection, the server checks the Origin header against a list of origins the application allows. If the origin is not on the list, the server refuses the upgrade.
This is the control that stops cross site WebSocket hijacking. It takes a few lines of code, and it is skipped surprisingly often because WebSocket libraries accept any origin by default.
Why it matters
WebSockets are not covered by the same origin policy or by CORS. A page on evil.example can open wss://app.example.com/socket from the victim's browser, and the browser will attach the victim's cookies to the handshake. If the server authenticates by cookie alone, the attacker's page now has a live, authenticated channel. It can read private messages as they arrive and send commands as the user.
The only thing that tells the server the request came from a hostile page is the Origin header, which browsers set and page scripts cannot change. See CWE-1385 for the weakness class.
Note that Origin is a browser protection. Non browser clients can send any value, so the check complements authentication and does not replace it.
How to implement it
Keep an explicit allowlist, compare exactly, and reject during the handshake before any message handling starts.
With the ws library in Node:
import { WebSocketServer } from 'ws';
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const wss = new WebSocketServer({
noServer: true,
});
server.on('upgrade', (req, socket, head) => {
const origin = req.headers.origin;
if (!origin || !ALLOWED.has(origin)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
Three details matter. Compare the full origin string, scheme and port included, never a suffix or a contains test, since app.example.com.evil.net passes a lazy check. Decide deliberately what to do with a missing Origin: browsers always send it, so for browser facing sockets reject it, and for machine clients use token authentication instead. Finally, pair the check with authentication on the handshake that does not rely on cookies alone, for example a short lived token (see V4.4.3 and V4.4.4).
Edge cases worth deciding in advance
Allowlists need maintenance. Keep the list in configuration, not scattered through code, so adding a new front end domain is a reviewed change. If you run preview environments with dynamic hostnames, do not turn the check into a wildcard. Either issue the previews a fixed set of origins or accept previews only in non production builds.
Be careful with reverse proxies. The Origin header is passed through unchanged by most proxies, but some CDNs and gateways rewrite or drop it. Check what your socket server actually receives, with a log line during staging tests, rather than assuming.
Here is the same control in Python with the websockets library, to show how little code it takes:
import websockets
ALLOWED = {"https://app.example.com", "https://admin.example.com"}
async def handler(ws):
...
async def main():
async with websockets.serve(handler, "127.0.0.1", 8080, origins=list(ALLOWED)):
await asyncio.Future()
When the origin is refused, the library responds with 403 before the connection opens, and your handler never runs.
Log refused origins with the value, the path and the client address. A burst of refusals from one address is worth an alert, since it suggests someone is trying a malicious page against your users or probing for a gap. And remember the limit of this check: it protects browsers. For other clients, require the dedicated token described in V4.4.3 and V4.4.4, apply rate limits on the handshake, and cap the number of open sockets per user so one account cannot hold thousands of connections.
How to test it
Run a handshake with a forged origin and see whether it is refused.
curl -i -N https://app.example.com/socket \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: https://evil.example"
Expect a 403, not a 101 Switching Protocols. Repeat with your real origin and expect 101. Then try near misses: https://app.example.com.evil.net, http://app.example.com (wrong scheme) and an origin with a different port. All must fail. For a full browser test, host a page on another domain that opens the socket and confirm the connection closes.
Common mistakes
- Using the library default, which accepts every origin.
- Matching with
startsWith,includesor a loose regular expression. - Allowing the string
nullas an origin, which sandboxed pages and local files can send. - Checking Origin only on connect to one path while another socket path stays open.
- Treating the check as authentication. A script outside a browser can claim any Origin.
Try it yourself
Click through it at your own pace, or open it full screen.
Quick check
Why can a hostile page connect to your WebSocket with the victim cookies attached?
Browsers do not apply the same origin policy to WebSockets, and cookies are sent on the handshake. Only the Origin check on the server stops it.
What should you compare Origin against?
An exact allowlist of full origins including scheme and port, never a substring or suffix match.
Sources
- OWASP Application Security Verification Standard, requirement V4.4.2 (level 2)
- RFC 6455, section 10.2 Origin considerations