0x55aa
← Back to Blog

WebSockets Security: Your Real-Time App Has a Real-Time Attack Surface 🔌

8 min read

WebSockets Security: Your Real-Time App Has a Real-Time Attack Surface 🔌

I'll be honest with you: the first time I shipped a WebSocket-powered feature in production, I basically duct-taped an HTTP API mindset onto a completely different protocol and called it a day. 😬

It worked. Users loved the real-time chat. And then someone in a security community Slack I'm part of asked me: "Hey, did you authenticate your WebSocket upgrade requests?"

Reader, I had not.

WebSockets are fantastic — persistent bidirectional connections, low latency, all the good stuff. But they open up an attack surface that most developers completely ignore because they're too busy high-fiving themselves about how smooth the real-time updates feel.

Let me walk you through the real risks, so you don't have to learn them the embarrassing way I did.

What Even IS a WebSocket Connection? 🤔

Before we break it, let's understand it. A WebSocket starts life as a normal HTTP request — the "handshake." Your browser sends an Upgrade: websocket header, the server agrees, and now you have a persistent TCP connection that stays open until someone closes it.

That "starts as HTTP" part is where the fun (read: danger) begins.

GET /ws/chat HTTP/1.1
Host: yourapp.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Once upgraded, it's a persistent tunnel. No more per-request auth headers. No standard HTTP middleware intercepting things. Just you, your client, and whatever trust assumptions you baked in at connection time.

Attack #1: The Authentication Black Hole 🕳️

Here's the thing about WebSocket connections — your normal HTTP auth middleware? It often doesn't run after the upgrade.

The dangerous way (what I was doing):

// Your REST endpoints have auth middleware
app.use('/api', authMiddleware);

// Your WebSocket? Crickets.
wss.on('connection', (ws) => {
  // No auth check! Just... accept everyone!
  ws.on('message', (msg) => {
    handleMessage(ws, msg); // Who sent this? No idea!
  });
});

This is the equivalent of putting a bouncer at the front door of a club and leaving the back door wide open.

The safe way:

wss.on('connection', (ws, req) => {
  // Validate the token from the upgrade request
  const token = new URL(req.url, 'ws://localhost').searchParams.get('token');

  const user = verifyJWT(token);
  if (!user) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // Attach user context to the connection
  ws.userId = user.id;
  ws.on('message', (msg) => handleMessage(ws, msg));
});

Pro Tip 💡: Pass a short-lived token (30-60 seconds) in the WebSocket URL query string. Generate it from your existing session/JWT just before initiating the connection. This way you're not sending long-lived credentials over the wire in a URL.

Attack #2: Cross-Site WebSocket Hijacking (CSWSH) 🎣

You know CSRF for forms? WebSockets have their own flavor, and it's arguably worse.

Because browsers automatically include cookies with WebSocket upgrade requests, a malicious website can initiate a WebSocket connection to YOUR application on behalf of a logged-in user. If you're only checking cookies for auth... you're cooked.

The attack scenario:

  1. Victim is logged into yourbank.com
  2. Victim visits evil-casino.com in another tab
  3. Evil site runs JavaScript: new WebSocket('wss://yourbank.com/ws')
  4. Browser sends upgrade request with the victim's cookies automatically
  5. If you only rely on cookies? That connection succeeds. 💀

The fix — always check the Origin header:

wss.on('connection', (ws, req) => {
  const origin = req.headers['origin'];
  const allowedOrigins = ['https://yourapp.com', 'https://www.yourapp.com'];

  if (!allowedOrigins.includes(origin)) {
    ws.close(4003, 'Forbidden: Invalid origin');
    return;
  }

  // Continue with auth...
});

Real Talk 🎙️: I've seen production apps in security audits that skip this check entirely. It's one line. Please do it.

Attack #3: Message Injection & Missing Validation 💉

In my experience building production systems, developers are meticulous about validating REST API payloads. They use schema validators, sanitize inputs, validate types. Then they add WebSocket support and suddenly JSON.parse() is the only "validation."

WebSocket messages are just... text. Or binary. Whatever the client sends. And if you're blindly trusting that data:

The dangerous pattern:

ws.on('message', (data) => {
  const msg = JSON.parse(data);

  // Just... do whatever the message says?
  db.query(`UPDATE rooms SET name = '${msg.roomName}'`);
  // ^ Yes, I've actually seen SQL injection via WebSocket in the wild
});

The safe pattern:

ws.on('message', (data) => {
  let msg;
  try {
    msg = JSON.parse(data);
  } catch {
    ws.send(JSON.stringify({ error: 'Invalid message format' }));
    return;
  }

  // Validate structure and types
  if (typeof msg.roomName !== 'string' || msg.roomName.length > 100) {
    ws.send(JSON.stringify({ error: 'Invalid room name' }));
    return;
  }

  // Use parameterized queries, always
  db.query('UPDATE rooms SET name = ? WHERE id = ?', [msg.roomName, ws.userId]);
});

Every WebSocket message handler is an API endpoint. Treat it like one.

Attack #4: The Resource Exhaustion Party 🎉 (That You Weren't Invited To)

WebSocket connections are persistent. That's the whole point. It's also a recipe for DoS if you're not careful.

A single server can handle thousands of WebSocket connections — but not infinite ones. And unlike HTTP requests that end quickly, WebSocket connections just... sit there. Holding resources. Breathing your RAM.

What attackers do: Open thousands of connections from distributed IPs. Your server memory fills up. Real users can't connect. Congrats, you've been DoS'd via the feature you're most proud of.

Defenses that actually work:

// Rate limit connection attempts per IP
const connectionCounts = new Map();

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  const count = (connectionCounts.get(ip) || 0) + 1;

  if (count > 10) { // Max 10 connections per IP
    ws.close(4029, 'Too many connections');
    return;
  }

  connectionCounts.set(ip, count);

  ws.on('close', () => {
    const current = connectionCounts.get(ip) || 1;
    connectionCounts.set(ip, current - 1);
  });

  // Also set a message rate limit per connection
  // and implement heartbeats to kill dead connections
});

Also: Implement ping/pong heartbeats to detect and terminate zombie connections. Dead clients don't tell you they're dead.

Attack #5: The Broadcast Disaster 📡

As someone who's architected multi-tenant SaaS backends, this one makes me physically uncomfortable to remember.

Real-time apps often broadcast messages to multiple users. If you get your room/channel membership logic wrong, User A sees User B's private messages. In a financial app, that's catastrophic. In a healthcare app, it's a HIPAA nightmare.

The dangerous pattern:

// Broadcast to "everyone in the room"
wss.clients.forEach((client) => {
  if (client.readyState === WebSocket.OPEN) {
    client.send(message); // Sent to ALL connected clients. Oops.
  }
});

The safe pattern — always scope broadcasts:

// Only broadcast to authenticated members of THIS specific room
wss.clients.forEach((client) => {
  if (
    client.readyState === WebSocket.OPEN &&
    client.roomId === targetRoomId &&    // Right room
    client.userId !== senderUserId &&     // Not the sender
    hasPermission(client.userId, targetRoomId) // Still authorized?
  ) {
    client.send(message);
  }
});

Pro Tip 💡: Re-validate permissions on every message, not just at connection time. User permissions can change. Tokens expire. A user might lose access to a room 30 seconds after connecting. Your app should handle that.

The WebSocket Security Checklist 📋

Before you deploy that real-time feature:

  • Authenticate during the upgrade handshake (not just "trust whoever connects")
  • Validate the Origin header to prevent CSWSH
  • Use short-lived tokens, not long-lived session cookies alone
  • Validate and sanitize every single message payload
  • Rate limit connections per IP
  • Rate limit messages per connection
  • Implement heartbeats to kill zombie connections
  • Scope all broadcasts — never broadcast to all clients blindly
  • Use wss:// (TLS) — never plain ws:// in production
  • Set a maximum message size to prevent memory bombs

Real Talk: Why This Gets Missed 🎙️

In security communities, we often discuss why WebSocket security lags behind REST API security. The answer is pretty simple: tooling.

Most API security scanners, WAFs, and penetration testing checklists are built around HTTP request/response cycles. WebSockets are persistent, bidirectional, and don't follow those patterns. Automated scanners often skip or poorly handle them. That gives developers a false sense of security — their scanner didn't flag anything, so they assume they're fine.

They are not fine.

When I do security reviews now, WebSocket implementations are one of the first things I check. The attack surface is almost always under-secured relative to the REST endpoints.

TL;DR 🎯

WebSockets give you real-time superpowers. They also give attackers a persistent, often under-guarded connection into your application.

  • Authenticate during the handshake
  • Validate the Origin to stop hijacking
  • Validate every message like it's a REST endpoint
  • Rate limit both connections and messages
  • Scope broadcasts to authorized users only
  • Use TLS (wss://) always

Your real-time feature is only cool if real attackers can't use it in real time against you.


Found a WebSocket vuln in the wild or want to geek out about real-time security? Hit me up on LinkedIn or check out my work on GitHub. As someone active in security communities and building production systems with real-time features, I've got plenty more war stories where this came from. 🔐