WebSocket Security
WebSockets bypass parts of the same-origin model that protect HTTP. Without explicit defenses, you have new attack surfaces: cross-origin handshake initiation (CSWSH), missing input validation, DoS via unbounded streams, and harder-to-monitor traffic.
For broader app security see ../../25_security/.
Origin validation — defending against CSWSH
Cross-Site WebSocket Hijacking. The attack:
- User logs into
bank.example.com, gets a session cookie. - User visits attacker’s site
evil.com. evil.comJS opensnew WebSocket("wss://bank.example.com/ws").- Browser includes
bank.example.comcookies in the handshake (same as for fetch). - The connection authenticates as the user.
evil.comJS now has bidirectional access to the user’s account.
CSWSH is to WebSockets what CSRF is to HTTP, but WebSockets don’t have CORS preflight. The browser doesn’t ask the server “is this origin allowed?” — the server has to check.
@app.websocket("/ws")
async def ws(websocket: WebSocket):
origin = websocket.headers.get("origin", "")
allowed = ["https://app.example.com", "https://www.example.com"]
if origin not in allowed:
await websocket.close(code=1008, reason="Forbidden origin")
return
# ...
Always validate Origin. Allowlist explicit origins; reject everything else.
Note: Origin is set by browsers automatically. Non-browser clients (Python, curl, malicious bots) can set anything. So Origin validation only protects against browser-launched attacks — but those are most CSWSH attacks.
For defense in depth: combine Origin validation with proper auth tokens (not just cookies; see 04_authentication.md).
CSRF tokens / SameSite cookies
If auth is cookie-based, set SameSite=Strict or SameSite=Lax on session cookies. This prevents cross-origin requests from including the cookie automatically, defeating CSWSH at the browser level.
response.set_cookie(
"session",
value=sid,
httponly=True,
secure=True,
samesite="strict", # or "lax"
)
Modern browsers default cookies to SameSite=Lax. For paranoid: Strict. Combined with Origin validation, CSWSH is hard.
Input validation
WebSocket messages are user input. Treat them like any other input:
- Length limits (per message, per second).
- Schema validation (Pydantic / JSON Schema).
- Encoding validation (UTF-8 for text frames is enforced by the protocol, but business validation still needed).
- SQL / NoSQL injection if message contents reach a query.
- Command injection if message contents reach
subprocess.
from pydantic import BaseModel, Field
class ChatMessage(BaseModel):
type: str = Field(..., pattern="^[a-z_]+$")
content: str = Field(..., max_length=2000)
room_id: str = Field(..., max_length=64)
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
while True:
raw = await websocket.receive_json()
try:
msg = ChatMessage(**raw)
except ValidationError:
await websocket.send_json({"error": "invalid_message"})
continue
# ... use validated msg
Per-connection rate limiting on messages:
class RateLimiter:
def __init__(self, max_per_min=60):
self.timestamps = collections.deque()
self.max = max_per_min
def check(self):
now = time.time()
while self.timestamps and now - self.timestamps[0] > 60:
self.timestamps.popleft()
if len(self.timestamps) >= self.max:
return False
self.timestamps.append(now)
return True
Apply per connection. Closing the connection after repeated violations.
Authorization per message
WebSocket auth typically happens at connect time. But what about per-message authorization?
async def handle(user, msg):
if msg["type"] == "delete_post":
if not user.can_edit_post(msg["post_id"]):
return {"error": "forbidden"}
...
Each message that affects data must check authorization. Connect-time auth says “this is user X”; per-message auth says “user X can do this action on this resource.”
Antipattern: authorize once at connect, treat all subsequent messages as “trusted.” User’s permissions change; their open connection still acts with old powers.
DoS via slow / malicious clients
| Vector | Defense |
|---|---|
| Idle unauthenticated connections | timeout the handshake / first-message auth |
| Slow read (server’s send buffer fills) | bounded send queue, drop or kick |
| Message flood | rate limit per connection |
| Giant messages | max_size setting on the library |
| Many small messages tying up CPU | rate limit + CPU profiling |
| Many connections from one IP | per-IP connection limit at LB |
Configure all of these. Default-no-limit is the attacker’s friend.
Encryption — wss:// only
Plain ws://:
- All traffic in plaintext.
- Tokens / cookies in handshake visible to anyone on the network path.
- Modern browsers refuse
ws://from HTTPS pages. - Some firewalls / proxies block / strip plain WebSocket.
Production = wss:// (WebSocket over TLS) always.
server {
listen 443 ssl http2;
server_name ws.example.com;
ssl_certificate ...;
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
TLS terminates at nginx; nginx ↔ backend is plain HTTP (over the trusted private network) or another TLS hop for zero-trust.
Avoid mixing trusted and untrusted data
A common bug:
# Server-side
async def broadcast(msg):
for conn in connections:
await conn.send_text(f"<div>{msg}</div>") # XSS if msg has HTML
User A sends <script>alert(1)</script>; server broadcasts to user B; user B’s frontend renders the message as HTML; XSS executes.
Mitigation:
- Send raw text, not HTML, over WebSocket.
- Client-side: escape on render (React’s
{msg}escapes by default; jQuery’s.html()does NOT). - Treat WebSocket messages as untrusted user input (because they are).
See ../../25_security/03_xss_csrf.md.
Avoid sending sensitive data unnecessarily
WebSocket traffic often isn’t audited as carefully as HTTP. Don’t leak:
- Internal IDs / stack traces in error responses.
- Other users’ data (broadcast scope bugs — sending Alice’s message to everyone, not just her room).
- Internal state (debug logs over WS).
- Permissions that the client doesn’t need to know about.
Test your broadcast logic: send a message in room A and verify users in room B don’t receive it.
Logging and monitoring
WebSocket traffic is harder to monitor than HTTP:
- Long-lived connections; “requests” don’t have a clean start/end for typical log aggregators.
- Frames aren’t HTTP, so HTTP-only WAFs don’t inspect them.
- Volume per connection can be huge (thousands of messages on one connection).
Approaches:
- Per-message logging: structured logs for each message handled. Costly but auditable.
- Per-connection lifecycle: log connect, disconnect, message count, error count.
- Sampled message logging: 1% of messages get full content logged.
- Anomaly detection: connections with >N messages/sec, >N MB/min, etc.
Datadog, NewRelic, custom Prometheus metrics can track per-connection stats.
Subprotocol negotiation hardening
@app.websocket("/ws")
async def ws(websocket: WebSocket):
protocols = websocket.headers.get("sec-websocket-protocol", "").split(", ")
allowed = ["chat.v1", "chat.v2"]
chosen = next((p for p in protocols if p in allowed), None)
if not chosen:
await websocket.close(code=1002, reason="Unsupported subprotocol")
return
await websocket.accept(subprotocol=chosen)
Reject unknown subprotocols. Forces clients to declare their protocol version.
If you use subprotocol as auth carrier (Pattern 3 in 04_authentication.md), validate the token immediately.
TLS version and cipher suites
Same hygiene as HTTPS:
- TLS 1.2 minimum (1.3 preferred).
- Disable old ciphers.
- HSTS via the host’s HTTP responses (browsers will then refuse
ws://).
See ../../28_networking/12_tls_https_certificates.md.
Common pitfalls
- No Origin check — CSWSH waiting to happen.
- Auth at connect only, no per-message authz — privilege escalation if message-shape isn’t sanitized.
- Plain
ws://in production — credential leakage on network. - No rate limiting per connection — one client floods the server.
- No max message size — single 10 GB message OOMs.
SameSite=Noneon session cookies without understanding CSRF implications.- Trusting message contents as HTML — XSS via broadcast.
- Per-connection logging at INFO — log volume explodes.
Common interview confusions
- “CORS protects WebSockets.” — no preflight for WebSockets. You have to check
Originmanually. - “
SameSitecookies prevent all CSWSH.” — strongly mitigate, but custom code paths or older browsers may not enforce. Defense in depth. - “WebSocket is more secure than HTTP.” — equivalent, with different attack surfaces. WebSocket has fewer built-in protections (no CORS, less middleware) so you have to add them.
Interview angle
- “What’s CSWSH?” — Cross-Site WebSocket Hijacking. Attacker’s site opens a WebSocket to your site; browser includes your cookies; the connection acts as the user. CSRF for WebSockets, but no built-in browser preflight. Defense: validate
Originheader on handshake +SameSitecookies + use tokens not cookies. - “How do you validate the Origin header?” — allowlist of expected origins; reject others with close code 1008. Note: Origin is browser-set; non-browser clients can lie. So this is browser-CSWSH defense, not general access control.
- “Should you use cookies or tokens for WebSocket auth?” — for browser apps, the ticket pattern (short-lived token from authenticated HTTP request, used in WS URL) is best. Cookies work but require Origin validation + SameSite + CSRF awareness.
- “How do you prevent DoS on WebSocket endpoints?” — auth before resource allocation (close unauth fast); rate limits per connection and per IP; max message size; bounded send queues; max connection count; timeout on idle.
- “What’s the security risk of plain
ws://?” — plaintext traffic; auth tokens and message contents visible to anyone on the network. Modern browsers refusews://from HTTPS pages. Always usewss://. - “How do you handle per-message authorization?” — connect-time auth establishes the user. Each action-message must check whether the user is allowed (
user.can_edit(post)). Don’t assume “connected = trusted for everything.” - “How does XSS via WebSocket happen?” — server broadcasts user-input messages without sanitization; client renders as HTML. Treat WS messages as untrusted user input; escape on render; never
.innerHTML = msg.