Skip to content

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?

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.1
Host: your-server
Upgrade: websocket
Connection: Upgrade
Origin: https://app.example.com
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Cookie: session=…

Maintain an allowlist of origins you trust and reject anything else before completing the upgrade.

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 the Origin check is mandatory when you authenticate by cookie.
  • A token in a subprotocol. Pass a bearer token via the Sec-WebSocket-Protocol header (the second argument to new 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 auth message. The server keeps the connection unauthenticated and silent until that frame validates.

And one anti-pattern to avoid above all:

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, &lt;token&gt;
  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
Origin check and token validation during the WebSocket handshake

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.

Node.js

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.

Why is the Origin header check on the server mandatory for cookie-authenticated WebSockets?
Where should an authentication token NOT be placed?
Which close code does the demo use to refuse a connection with a bad origin or token?