Origin and Handshake Auth
The first place a WebSocket can betray you is the handshake — the one HTTP request that upgrades a plain connection into a socket. Two questions must be answered there, and if you skip either one, nothing later can save you: which page is opening this connection? and who is the user behind it?
WebSockets are not CORS-protected
Section titled “WebSockets are not CORS-protected”Here is the trap that catches almost everyone. When a page calls fetch('https://your-api/...') across origins, the browser runs the CORS machinery: it may send a preflight, and it will hide the response unless your server opts in with the right headers. That protection is automatic.
A WebSocket upgrade gets none of it. When any page — including https://evil.example — runs this:
const ws = new WebSocket('wss://your-server/socket');the browser does not send a CORS preflight, does not consult the same-origin policy, and does attach the user’s cookies for your-server if they exist. The upgrade request leaves the browser and arrives at your server looking exactly like a legitimate one. If the user is logged in, the attacker’s page now has an authenticated socket. This is Cross-Site WebSocket Hijacking — CSRF’s cousin.
The one thing the browser does give you is an Origin header on the handshake, set to the page that opened the socket and which scripts cannot forge. Reading and checking that header is the defence, and it is entirely on your side of the wire.
GET /socket HTTP/1.1Host: your-serverUpgrade: websocketConnection: UpgradeOrigin: https://app.example.comSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Cookie: session=…Maintain an allowlist of origins you trust and reject anything else before completing the upgrade.
Authenticate during the handshake
Section titled “Authenticate during the handshake”Origin tells you which page. You still need which user. Authenticate the connection while it is still an HTTP request — that is the moment you have headers, cookies, and the freedom to refuse cleanly. There are three common ways to carry the credential:
- A session cookie. If the user is already logged in on
https://app.example.com, the handshake carries their cookie automatically. Validate it server-side. This is convenient but is precisely what makes Cross-Site WebSocket Hijacking possible — which is why theOrigincheck is mandatory when you authenticate by cookie. - A token in a subprotocol. Pass a bearer token via the
Sec-WebSocket-Protocolheader (the second argument tonew WebSocket(url, protocols)). It travels in a header, not a logged location, and the server can read it during the upgrade. - A token in the first message. Open the socket, then make the client’s very first frame an
authmessage. The server keeps the connection unauthenticated and silent until that frame validates.
And one anti-pattern to avoid above all:
The authenticated handshake, step by step
Section titled “The authenticated handshake, step by step”sequenceDiagram
participant C as Browser (page)
participant S as WebSocket server
C->>S: GET /socket Upgrade: websocket<br/>Origin: app.example.com<br/>Sec-WebSocket-Protocol: bearer, <token>
S->>S: 1. Origin in allowlist?
alt Origin not allowed
S-->>C: reject upgrade / close 1008
else Origin OK
S->>S: 2. Validate token / cookie
alt Token invalid or expired
S-->>C: close 1008 (policy violation)
else Authenticated
S-->>C: 101 Switching Protocols
Note over C,S: Connection open — identity now fixed
end
end Notice both checks happen before 101 Switching Protocols. A rejected origin or a bad token never becomes an open socket — and when we do close, we use code 1008 (policy violation), the standard signal that the connection was refused for a rule, not a network fault.
A runnable server that rejects bad origins and tokens
Section titled “A runnable server that rejects bad origins and tokens”The demo below is the body of the connection handler for a real ws server. The ws library hands you req — the original upgrade request — so its headers are still available. We check req.headers.origin against an allowlist, then pull a token from the Sec-WebSocket-Protocol header and validate it. Anything that fails gets ws.close(1008, …). Open it in StackBlitz and run npm run client to watch a connection get accepted or refused.
Needs the Node.js runtime — open in StackBlitz to run.
The starter’s client opens with a localhost origin but no token, so you will see the server reject it with close 1008 and invalid token — exactly the refusal an attacker’s page would hit. Edit VALID_TOKENS, or have the client pass a subprotocol, to see the accepted path. The key idea: by the time the socket is open, both which page and which user are already settled, and the answer to “who is this?” is now stored on the connection itself.