Securing the WebSocket upgrade and the frames after it: Origin validation against Cross-Site WebSocket Hijacking, authenticating the handshake rather than the first message, the ticket pattern for browsers that cannot set headers, per-frame authorization, and resource limits on a connection that stays open. Use when generating a WebSocket, Socket.IO, SignalR, or Phoenix Channels server, wiring real-time messaging, presence, or collaborative editing, or reviewing a /ws endpoint.
Installs into .claude/skills of the current project.
Are you the author of Websocket Security?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/shieldnet-360-websocket-security-secure-vibe)
---
name: websocket-security
description: "Securing the WebSocket upgrade and the frames after it: Origin validation against Cross-Site WebSocket Hijacking, authenticating the handshake rather than the first message, the ticket pattern for browsers that cannot set headers, per-frame authorization, and resource limits on a connection that stays open. Use when generating a WebSocket, Socket.IO, SignalR, or Phoenix Channels server, wiring real-time messaging, presence, or collaborative editing, or reviewing a /ws endpoint."
---
<!-- Native skill bundle for Claude Code. Generated by `secure-vibe dev regenerate`. -->
<!-- Do not edit by hand; the source of truth is skills/websocket-security/SKILL.md. -->
# WebSocket Security
Securing the WebSocket upgrade and the frames after it: Origin validation against Cross-Site WebSocket Hijacking, authenticating the handshake rather than the first message, the ticket pattern for browsers that cannot set headers, per-frame authorization, and resource limits on a connection that stays open. Use when generating a WebSocket, Socket.IO, SignalR, or Phoenix Channels server, wiring real-time messaging, presence, or collaborative editing, or reviewing a /ws endpoint.
## ALWAYS
- Validate the **`Origin` header** on the upgrade handshake against an allowlist, and understand precisely what that buys: the browser sets `Origin` and script cannot forge it, so the check stops **Cross-Site WebSocket Hijacking** — a page on `attacker.com` opening `wss://api.example.com/ws` and inheriting the user's cookies. It buys nothing against a non-browser client, which sets any header it likes. Origin is a CSRF control, not authentication. The same-origin policy and CORS preflight do not apply to the upgrade at all, which is why this check must be written by hand.
- Authenticate the **handshake**, not the first frame. Once the upgrade completes the connection exists, has consumed the user's cookie context, and is already a resource the caller controls. A `subscribe` or `auth` message afterwards is too late.
- For a browser client, pick one of two mechanisms, because the browser `WebSocket` constructor **cannot set request headers** — there is no `Authorization` header available to you: 1. **Cookie on the upgrade** plus the `Origin` check above as the CSRF control, or 2. A **single-use ticket**: the authenticated page calls an ordinary HTTP endpoint, receives a short-lived opaque ticket bound to the user and to the intended resource, passes it in the URL, and the server **redeems and invalidates it at handshake time**. A ticket is safe in a URL precisely because replaying it fails.
- Re-authorize **every frame**, both the action and the subject it names. A frame like `{"action":"write","subjectId":"X"}` must be checked against the principal established at handshake — an authenticated socket must never be able to assert an arbitrary subject id per frame. That is per-frame BOLA, and the forged id reaches every consumer downstream of the socket that trusts it. Permissions also change while the connection is open: logout, role change, account lock, token expiry. A socket opened an hour ago carries an authorization decision an hour old.
- Bound the connection: maximum frame and message size, messages per second per connection, concurrent connections per user and per source address, and total connection lifetime after which the client must re-handshake. A WebSocket is an allocation that persists, so every limit HTTP gets per-request must be re-expressed per-connection. Concrete starting values, and the backpressure and heartbeat settings, are in `references/limits-and-handshake.md`.
- Use **`wss://`**. `protocol-security` owns the TLS configuration behind it; what belongs here is that `ws://` exposes the session token, every frame, and the handshake itself to any on-path observer.
- Consult `auth-security` for the token's own properties — lifetime, rotation, and why a bearer token does not belong in a URL — and `cors-security` for the origin allowlist itself, including how to treat the serialized `null` origin.
## NEVER
- Skip Origin validation on the grounds that "CORS does not apply to WebSockets." That is the reason the check is required, not a reason to omit it.
- Put a **bearer token** — a JWT, a session id, an API key — in the WebSocket URL. URLs reach access logs, proxy logs, `Referer` headers and browser history, and a token that survives being read there is a token an attacker can replay. The single-use ticket above is the form that is safe in a URL; a reusable credential is not, and `auth-security` owns that rule for every other transport.
- Treat a session cookie as a long-lived connection credential that outlives the session it came from. If the socket must survive re-authentication, redeem a fresh ticket on reconnect.
- Let a client-supplied **subprotocol** influence server-side routing or handler selection without an allowlist. Subprotocol negotiation is attacker-controlled input.
- Run WebSocket handlers on the same bounded thread pool or event loop as HTTP request handlers with no separate sizing. A slow or chatty socket then starves ordinary request work, which is the same failure as a slow-loris with a longer lease.
- Put internal topology into frames — pod names, node ids, upstream hostnames, internal error text. A real-time channel is a chatty reconnaissance surface.
- Reconnect without **bounded exponential backoff and jitter** on the client. An outage otherwise converts every client into a synchronized retry flood at the moment the service is least able to absorb it.
## KNOWN FALSE POSITIVES
- A **deliberately public** endpoint — a public presence or broadcast feed carrying no per-user data — may accept any origin. What it may not skip is the per-connection and per-source limits, because an open endpoint is the one most exposed to them.
- A native mobile or desktop client sends **no `Origin` header at all**, which is a different case from the string `null`. Absent means "not a browser": decide whether to admit it and authenticate it some other way. `null` means an opaque origin — a sandboxed iframe, a `data:` URL, a `file://` document — and on a credentialed endpoint it should be rejected, exactly as `cors-security` requires.
- A frame that carries no subject id, because the connection is scoped to one resource at handshake, is not missing per-frame authorization. Binding the subject once, at the upgrade, is the stronger design.
- Plaintext `ws://` between services is acceptable where a **service mesh terminates mTLS** underneath it, because the mesh supplies a cryptographic peer identity. It is not acceptable because the traffic is inside a VPC — a private network is a location, not an authentication.
## Reference files
Read these only when the task calls for them.
- `references/limits-and-handshake.md`
- `references/verifying-findings.md`