Most developers ship a WebSocket-based chat feature, slap TLS on it, and consider security done. It is not done. Not even close.
Encryption protects data in transit. It does nothing about what happens after the connection is authenticated. And in real-time architectures, the most dangerous vulnerabilities live entirely in that post-authentication space.
The Authorization Problem Nobody Talks About
Broken Object-Level Authorization (BOLA) is the top entry on the OWASP API Security list for a reason. In REST APIs, it shows up when a user changes an ID in a URL and gets back someone else's data. In WebSocket chat systems, it is sneakier.
Consider a support chat where each conversation has a thread ID. The client sends a message like this to subscribe to a thread:
{ "action": "subscribe", "thread_id": "thread_8821" }
Your server authenticates the connection. It validates the JWT. It confirms the user is logged in. Then it hands over thread_id: thread_8821 without checking whether that user has any business reading that thread.
An attacker increments the ID. Or they enumerate IDs. Or they grab a thread ID from a URL in a shared screenshot. The connection is authenticated, so your logs show no anomaly. The user is just... reading someone else's support conversation.
The fix is not complicated, but it requires a deliberate step that teams skip because the authentication already passed:
def handle_subscribe(user_id, thread_id):
thread = get_thread(thread_id)
if thread is None or thread.owner_id != user_id:
raise PermissionDenied("Access to thread not authorized")
subscribe_user_to_thread(user_id, thread_id)
Every object reference that comes in over a WebSocket frame needs its own authorization check. The connection being authenticated is not a blanket permission to access any resource the client names.
Rate Limiting Needs a Different Mental Model Here
HTTP rate limiting is usually request-based: 100 requests per minute per IP, per token, per whatever. That model breaks down with WebSockets because a single persistent connection can flood your server with thousands of messages per minute while appearing as exactly one connection.
You are not limiting requests. You are limiting message throughput per connection.
The practical approach has two layers. First, you throttle messages at the connection level. If a single WebSocket connection sends more than, say, 20 messages in a 10-second window, you start dropping or delaying frames. Second, you limit concurrent connections per user. An authenticated user should not be able to open 500 simultaneous connections and use them to exhaust your server's socket capacity.
Connection exhaustion attacks do not look dramatic. They look like a slow memory climb and degraded latency for legitimate users. By the time you notice something is wrong, the damage is already there.
A simple token bucket approach on the server side works well here. Each connection gets a bucket with a fixed capacity. Each message costs a token. The bucket refills at a controlled rate. Messages that arrive when the bucket is empty get dropped, and the connection gets a warning. Repeated violations close the connection.
Baseline Policies Are Not Bureaucracy
Before you get anywhere near authentication or authorization, there is a layer of chat policies that should be non-negotiable defaults. Teams treat these as optional hardening, but they are actually your first line of defense.
Message size caps prevent payload-based attacks. A WebSocket server that accepts unbounded message sizes will eventually receive a 500MB JSON blob from someone curious about what happens. Set a hard limit, something like 64KB for a typical support chat message, and reject anything above it at the frame level before you even parse the content.
Idle timeouts close connections that have gone quiet. Stale connections accumulate. They hold memory, file descriptors, and session state. A connection that has been idle for 10 minutes in a support chat context should be closed gracefully. Let the client reconnect if the user is still there.
Origin validation is the one that gets skipped most often because WebSocket handshakes happen over HTTP upgrade requests, and developers assume the browser handles it. The browser does enforce same-origin policy for some things, but your server should explicitly check the Origin header on every handshake and reject connections from origins that are not on your allowlist. This is not paranoia; it is a 10-line check that closes off an entire class of cross-site WebSocket hijacking scenarios.
What This Looks Like in Practice
Secure WebSocket chat is not a single feature you add. It is a stack of deliberate decisions, each addressing a different failure mode. The baseline policies handle the structural stuff before a message even gets processed. Rate limiting handles abuse at the connection layer. And per-action authorization handles the actual data access decisions that encryption was never designed to protect.
Developers who have only worked with HTTP APIs sometimes expect the security model to carry over directly. It does not. WebSockets are stateful, long-lived, and bidirectional. Each one of those properties introduces constraints that require you to think about security differently.
The concrete takeaway: treat every incoming WebSocket frame as an untrusted API call from an authenticated but not authorized user, and build your security checks around that assumption rather than around the connection handshake alone.
Top comments (0)