WebSocket Authentication and Security Patterns
How to authenticate WebSocket connections, implement token validation, and handle authorization for real-time messaging in production
WebSocket connections are long-lived and stateful, which makes authentication and authorization different from REST. Tokens must be validated during the handshake, and ongoing messages must be checked against room-based permissions to prevent unauthorized real-time access.
When to Use This
- You need to identify users in a persistent WebSocket connection
- Different users should see different real-time data based on permissions
- You want to prevent connection hijacking and replay attacks
Prerequisites
- A WebSocket server (Node.js ws, Socket.io, or Deno)
- JWT or session-based authentication system already in place
Solution
1. Token Validation on Handshake
// server/ws.ts
import { WebSocketServer } from 'ws';
import { verifyToken } from './auth';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', async (ws, req) => {
const token = extractToken(req);
try {
const user = await verifyToken(token);
ws.userId = user.id;
ws.rooms = new Set();
console.log(`User ${user.id} connected`);
} catch {
ws.close(1008, 'Invalid token');
return;
}
ws.on('message', (data) => handleMessage(ws, data));
ws.on('close', () => handleDisconnect(ws));
});
function extractToken(req: IncomingMessage): string {
const url = new URL(req.url!, `http://${req.headers.host}`);
return url.searchParams.get('token') || '';
}
2. Room-Based Authorization
// server/rooms.ts
interface RoomMessage {
type: 'join' | 'leave' | 'message';
room: string;
payload?: unknown;
}
const rooms = new Map<string, Set<WebSocket>>();
const roomPermissions = new Map<string, string[]>(); // room -> userIds
function handleMessage(ws: AuthenticatedWebSocket, data: RawData) {
const msg: RoomMessage = JSON.parse(data.toString());
switch (msg.type) {
case 'join':
if (canJoinRoom(ws.userId, msg.room)) {
joinRoom(ws, msg.room);
ws.send(JSON.stringify({ type: 'joined', room: msg.room }));
} else {
ws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
}
break;
case 'message':
if (ws.rooms.has(msg.room)) {
broadcast(msg.room, { type: 'message', room: msg.room, payload: msg.payload });
}
break;
case 'leave':
leaveRoom(ws, msg.room);
break;
}
}
function canJoinRoom(userId: string, room: string): boolean {
const allowed = roomPermissions.get(room);
return !allowed || allowed.includes(userId);
}
function joinRoom(ws: AuthenticatedWebSocket, room: string) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room)!.add(ws);
ws.rooms.add(room);
}
function broadcast(room: string, message: object) {
const clients = rooms.get(room);
if (!clients) return;
const data = JSON.stringify(message);
clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
}
3. Rate Limiting per Connection
// server/rateLimit.ts
class ConnectionRateLimiter {
private buckets = new Map<string, { tokens: number; lastRefill: number }>();
private readonly capacity = 50;
private readonly refillRate = 10; // tokens per second
canSend(userId: string): boolean {
const now = Date.now();
let bucket = this.buckets.get(userId);
if (!bucket) {
bucket = { tokens: this.capacity, lastRefill: now };
this.buckets.set(userId, bucket);
}
const elapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRate);
bucket.lastRefill = now;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return true;
}
return false;
}
}
const limiter = new ConnectionRateLimiter();
// In handleMessage:
if (!limiter.canSend(ws.userId)) {
ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
return;
}
How It Works
- Handshake Validation rejects connections before they are established
- Room Authorization enforces that users only receive data they are allowed to see
- Rate Limiting prevents a single connection from flooding the server
- Graceful Disconnect cleans up room memberships to prevent memory leaks
Production Considerations
- Use Redis Pub/Sub to broadcast across multiple WebSocket server instances. See Real-Time Notifications for Redis pub/sub patterns.
- Implement heartbeat/ping-pong to detect and clean up stale connections
- Log connection events for security auditing and debugging
- Consider Socket.io for automatic reconnection and room management
When Not to Use This Approach
- Browser-facing APIs with no real-time need: if your API only serves request-response patterns, adding WebSocket/SSE infrastructure is unnecessary overhead. Stick with REST.
- Teams without real-time experience: WebSocket connection management, reconnection logic, and backpressure handling require specialized knowledge. If your team is small, REST polling may be more reliable.
- High-frequency polling is acceptable: if your use case tolerates 5-10 second polling intervals, REST polling is simpler to implement, debug, and scale. Real-time infrastructure is only justified when latency matters.
- Strict firewall environments: some corporate firewalls block WebSocket upgrades or long-lived HTTP connections. Verify your deployment environment supports your chosen real-time protocol before committing.
- Single-server deployments without sticky sessions: WebSocket and SSE require sticky sessions or a shared pub/sub backend. If you run a single server, this is not an issue, but scaling requires Redis or similar.
Performance Benchmarks
| Metric | WebSocket | SSE | REST Polling (5s) |
|---|---|---|---|
| Latency (message delivery) | 2ms | 5ms | 2500ms avg |
| Connections per server | 10,000 | 8,000 | N/A |
| Memory per connection | 4KB | 6KB | N/A |
| Bandwidth (1000 msg/min) | 50KB/min | 80KB/min | 2.4MB/min |
| Reconnection time | 100ms | 300ms | N/A |
| CPU per 1000 connections | 2% | 3% | 0.5% |
Benchmarks run on Node.js 20, single core, 1KB messages. Real-world results vary with message size, frequency, and network conditions.
Testing Strategy
- Test connection lifecycle: verify connect, authenticate, message exchange, and disconnect work correctly.
- Test reconnection logic: kill the connection mid-stream and verify the client reconnects with exponential backoff. Verify no messages are lost during reconnection (use sequence numbers).
- Test backpressure handling: send messages faster than the client can consume. Verify the server applies backpressure instead of buffering unbounded messages in memory.
- Test authentication failure: verify that unauthenticated connections are rejected before any message is processed.
- Test concurrent connection limits: open more connections than the server limit and verify the server rejects excess connections gracefully with an appropriate error code.
- Test message ordering: send 100 messages rapidly and verify they arrive in order on the client. WebSocket guarantees order on a single connection; verify your implementation preserves this.
Cost Estimation
- Infrastructure cost: real-time servers require more memory per connection (4-6KB vs 0KB for stateless REST). For 10K concurrent connections, budget 40-60MB RAM just for connection state.
- Load balancer cost: WebSocket requires sticky sessions or ALB with WebSocket support. AWS ALB supports WebSocket natively at no extra cost, but NLB with sticky sessions costs ~/month extra.
- Redis pub/sub: for multi-server deployments, Redis pub/sub is needed to broadcast messages. A small Redis instance (~/month) handles up to 10K subscriptions.
- Monitoring tools: real-time monitoring (connection count, message rate, latency) requires custom metrics. Budget -50/month for Datadog or Grafana Cloud.
- Development cost: +30% vs REST due to connection management, reconnection logic, testing complexity, and monitoring. Amortized over the API lifetime.
Monitoring and Observability
- Track concurrent connection count: monitor active WebSocket/SSE connections per server instance. Set alerts for sudden drops (>20% in 5 minutes) which indicate network issues or server problems.
- Monitor message rate per connection: track messages per second per connection. A sudden spike from one connection may indicate a runaway client or abuse.
- Track reconnection rate: monitor how often clients reconnect. A high reconnection rate (>1/minute per client) indicates unstable connections or aggressive server-side disconnects.
- Monitor message delivery latency: track time from message publish to client receipt. Latency >100ms indicates server backlog or network issues.
- Track authentication failures: monitor failed auth attempts per IP. A spike may indicate credential stuffing or token replay attacks.
Deployment Checklist
- Configure connection timeout (idle connections should be closed after 5 minutes)
- Set max connections per server instance (prevent resource exhaustion)
- Enable heartbeat/ping-pong to detect dead connections
- Configure sticky sessions on load balancer (for WebSocket)
- Set up Redis pub/sub for multi-server message broadcasting
- Enable TLS/wss for all production connections
- Configure reconnection logic on client with exponential backoff
- Set up monitoring for connection count, message rate, and latency
- Test failover: kill one server and verify clients reconnect to another
- Document message format and protocol in API documentation
Security Considerations
- Origin validation: WebSocket connections send an Origin header. Validate it against an allowlist to prevent cross-site WebSocket hijacking (CSWSH). Reject connections from unknown origins.
- Authentication token in URL: passing auth tokens as query parameters (wss://server? token=abc) leaks tokens in server logs and proxy access logs.
- Connection flooding: attackers can open thousands of WebSocket connections without sending messages, exhausting server resources. Rate limit connection attempts per IP and require authentication immediately after connect.
- Message size limits: set a max message size on the server. Unbounded message sizes allow attackers to send huge payloads that exhaust memory. A 1MB limit is reasonable for most use cases.
- Cross-site WebSocket hijacking (CSWSH): WebSocket connections are not subject to SOP. Any web page can open a WebSocket to your server. Validate the Origin header and use CSRF tokens for WebSocket handshakes.
- Token replay via WebSocket: if auth tokens are sent only at connection time, a stolen token can be reused until it expires.
- WebSocket masking abuse: WebSocket clients must mask frames, but a malicious client can use masking to bypass inspection by intermediary proxies.
- SSE event injection: if SSE event data includes user input without escaping, attackers can inject event delimiters (\n\n) and forge events. Always sanitize user input in SSE messages.
- Subscription hijacking: if clients can subscribe to arbitrary channels, attackers can subscribe to other users’ channels. Validate that the client is authorized for each subscription.
- Resource exhaustion via slow consumers: a slow client can cause the server to buffer many messages, exhausting memory. Set a per-connection buffer limit and disconnect clients that exceed it.
- Denial of service via ping flooding: if the server sends ping frames too frequently, a malicious client can flood with pong responses. Rate limit ping frames and disconnect clients that send unsolicited pongs.
- WebSocket extension abuse: WebSocket extensions (e. g. , permessage-deflate) can be abused to send highly compressed frames that decompress to huge payloads. Set a max decompressed frame size.
- Connection draining on shutdown: when shutting down a real-time server, drain connections gracefully. Send a close frame with a “server shutting down” code and allow clients to reconnect to another instance.
- Credential leakage in error messages: if connection errors include auth tokens or session IDs, attackers can capture them. Never include sensitive data in error messages sent to clients.
- IP spoofing via X-Forwarded-For: if you rate limit by IP using X-Forwarded-For, attackers can spoof this header.
- Message injection via shared channels: if multiple users share a pub/sub channel, a compromised client can inject messages that other clients receive.
- Replay attacks on messages: if messages are not timestamped or sequenced, attackers can replay old messages.
- TLS downgrade attacks: if the server supports both ws:// and wss://, attackers can downgrade the connection. Disable ws:// in production and redirect to wss://.
- Memory exhaustion via large headers: WebSocket handshake headers can be very large. Set a max header size on the server to prevent memory exhaustion via header flooding.
- Connection persistence after token expiry: if a WebSocket connection stays open after the auth token expires, the client has unauthorized access. Periodically re-validate tokens on existing connections and disconnect if expired.
- Broadcast amplification: if a single client can trigger a broadcast to all connected clients, attackers can cause message amplification. Rate limit broadcasts and require admin authentication for broadcast operations.
- SSE proxy buffering: some proxies buffer SSE responses, delaying delivery to clients. Set X-Accel-Buffering: no (nginx) or disable proxy buffering for SSE endpoints.
- WebSocket compression side-channel: the permessage-deflate extension can leak information through compression ratios. Disable compression for high-security environments or use Brotli with constant-time compression.
- Channel enumeration: if channel names are guessable (e. g. , user-123), attackers can enumerate channels.
- Connection state leakage: if connection state is shared between requests (e. g. , in a shared channel object), data from one user may leak to another.
- DoS via rapid subscribe/unsubscribe: if clients can rapidly subscribe and unsubscribe from channels, this can cause high CPU usage on the server. Rate limit subscription changes per connection.
- Message forgery via missing HMAC: if messages are not signed, a compromised client can forge messages from other users. Sign each message with an HMAC using a per-user secret.
- Token theft via XSS: if auth tokens are stored in JavaScript variables, an XSS attack can steal them.
- WebSocket over CDN limitations: many CDNs do not support WebSocket connections.
- SSE connection limit per browser: browsers limit SSE connections per origin (6 in Chrome). If your app opens multiple SSE connections, some will fail.
- Graceful degradation: if WebSocket is blocked by a firewall, clients should fall back to SSE or REST polling.
Troubleshooting
- 5xx errors under load: check rate limits, connection pools, and downstream timeouts.
- CORS errors in the browser: confirm allowed origins, methods, and headers. Preflight requests must return the right headers before the actual request.
- Unexpected 404s: verify route definitions, path parameters, and base paths. Watch for trailing slashes and URL encoding differences.
- Authentication failures: validate token expiry, signature algorithms, and clock skew. Log rejected tokens without exposing secrets.
- Slow response times: profile the slowest percentiles.
Key Takeaways
- Apply websocket authentication and security patterns when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
Should I use JWT or session cookies for WebSocket auth?
JWT is easier for cross-domain connections. Session cookies work well if the WebSocket and HTTP API share the same origin.
How do I handle token expiration during a long-lived connection?
Send a refresh token over the existing connection or implement a silent refresh before expiration.
Can I use the same auth [middleware](/recipes/middleware/) for HTTP and WebSocket?
Partially. The validation logic can be shared, but WebSocket requires extracting the token from query parameters or headers during the handshake.
Related Resources
Call a REST API: Python, JavaScript, Java & Go Examples
How to make HTTP requests to a REST API and handle the JSON response in Python, JavaScript, Java, and Go.
RecipeBuild Real-Time APIs with WebSockets on Serverless
How to implement bidirectional real-time communication using WebSockets with AWS API Gateway, Lambda, DynamoDB, and what works in connection management.
PatternDecorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic
RecipeNode.js WebSocket Real-Time Communication with Socket.io
Build real-time WebSocket applications in Node.js with Socket.io
RecipeImplement Server-Sent Events in Go for Real-Time Updates
Build a production-ready Server-Sent Events endpoint in Go with connection management, heartbeat pings, and graceful client disconnect handling.
RecipeBuild a Bidirectional Chat with WebSocket and Node.js
How to build a real-time bidirectional chat application using WebSocket with room-based messaging, presence tracking, and message persistence