WebSocket Security Best Practices
WebSocket connections bypass some browser security mechanisms that protect HTTP requests. The server is responsible for validating origins, authenticating clients, and handling untrusted input. If you ignore these responsibilities, you open your application to session hijacking, data injection, denial of service, and cross-site attacks.
Unlike standard HTTP endpoints, WebSocket connections are long-lived and bidirectional. A single compromised connection can stream malicious data to your server for hours. The attack surface is larger than a typical REST API, and the protections you rely on for HTTP do not automatically apply. You need to implement security at every layer: transport, handshake, connection lifecycle, and message handling.
Always Use WSS (TLS)
The ws:// protocol sends all data in plaintext. Anyone on the network path between client and server can read and modify WebSocket frames. This includes ISPs, Wi-Fi hotspot operators, corporate proxies, and attackers performing man-in-the-middle (MITM) attacks on local networks.
Always use wss:// in production. WSS runs WebSocket over TLS, providing the same encryption guarantees as HTTPS. Here is why this matters:
- MITM attacks: Without TLS, an attacker on the same network can intercept frames, read sensitive data, and inject malicious messages into the stream. The client and server have no way to detect this tampering.
- Mixed content blocking: Modern browsers block
ws://connections from pages served over HTTPS. If your site uses HTTPS (and it should), plainws://connections will fail silently in many browsers. - Proxy interception: Many corporate and ISP proxies inspect unencrypted traffic. Some proxies buffer or modify WebSocket frames, breaking your connection. TLS prevents this interference because proxies cannot read the encrypted content.
- Credential exposure: If you pass authentication tokens during the WebSocket handshake, plaintext connections expose those tokens to anyone monitoring the network.
Configure your TLS certificates properly. Use certificates from a trusted CA, enforce TLS 1.2 or higher, and disable weak cipher suites. The same TLS configuration best practices that apply to your HTTPS server apply to WSS.
Origin Validation
When a browser opens a WebSocket connection, it sends an Origin header in the HTTP upgrade request. This header contains the origin of the page that initiated the connection. Your server must check this header and reject connections from unexpected origins.
CORS does not apply to WebSocket connections. The browser does not perform a preflight request, and the server does not return Access-Control-Allow-Origin headers. The browser sends the upgrade request directly, and it is the server’s job to inspect the Origin header and decide whether to accept or reject the connection.
Here is how to validate the origin using the ws library in Node.js:
const { WebSocketServer } = require('ws');
const ALLOWED_ORIGINS = [
'https://yourapp.com',
'https://www.yourapp.com',
];
const wss = new WebSocketServer({
port: 8080,
verifyClient: (info, callback) => {
const origin = info.origin || info.req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
callback(false, 403, 'Origin not allowed');
return;
}
callback(true);
},
});
A few things to keep in mind:
- Non-browser clients (like
curl, Postman, or custom scripts) can set theOriginheader to any value. Origin validation protects against browser-based attacks, not against malicious clients. You still need proper authentication. - If your application is accessed from multiple subdomains, validate against a pattern rather than a static list.
- Never skip origin validation just because you also have authentication. The two protections address different threat models.
Authentication
A WebSocket connection does not carry credentials automatically on every message the way cookies travel with every HTTP request. You need to authenticate the client during or immediately after the handshake. There are three common approaches, each with tradeoffs.
Token in Query String
The simplest approach is to pass a JWT or session token as a query parameter in the connection URL:
// Client
const token = getAuthToken();
const ws = new WebSocket(`wss://api.yourapp.com/ws?token=${token}`);
// Server
const wss = new WebSocketServer({
port: 8080,
verifyClient: (info, callback) => {
const url = new URL(info.req.url, 'wss://localhost');
const token = url.searchParams.get('token');
try {
const user = verifyJWT(token);
info.req.user = user;
callback(true);
} catch (err) {
callback(false, 401, 'Invalid token');
}
},
});
This is easy to implement, but the token appears in the URL. URLs end up in server access logs, browser history, and referrer headers. Rotate tokens frequently and keep their lifetime short if you use this approach.
Cookie-Based Authentication
If your application already uses session cookies for HTTP requests, you can reuse them for WebSocket connections. The browser automatically sends cookies during the upgrade handshake:
// Server
const wss = new WebSocketServer({
port: 8080,
verifyClient: async (info, callback) => {
const cookies = parseCookies(info.req.headers.cookie);
const sessionId = cookies['session_id'];
if (!sessionId) {
callback(false, 401, 'No session');
return;
}
const session = await sessionStore.get(sessionId);
if (!session) {
callback(false, 401, 'Invalid session');
return;
}
info.req.user = session.user;
callback(true);
},
});
Cookie-based auth works well with existing session infrastructure, but it introduces Cross-Site WebSocket Hijacking risks. You must validate the Origin header alongside the cookie to prevent CSWSH.
First-Message Authentication
With this approach, you accept the WebSocket connection first and require the client to send credentials as the first message:
// Client
const ws = new WebSocket('wss://api.yourapp.com/ws');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
});
// Server
wss.on('connection', (ws) => {
let authenticated = false;
let authTimeout = setTimeout(() => {
ws.close(4001, 'Authentication timeout');
}, 5000);
ws.on('message', (data) => {
if (!authenticated) {
clearTimeout(authTimeout);
const msg = JSON.parse(data);
if (msg.type === 'auth' && verifyToken(msg.token)) {
authenticated = true;
ws.send(JSON.stringify({ type: 'auth_ok' }));
} else {
ws.close(4001, 'Authentication failed');
}
return;
}
// Handle normal messages
handleMessage(ws, data);
});
});
This keeps tokens out of URLs and works when cookies are not available (such as cross-origin connections or mobile apps). The connection is open briefly before authentication completes. Set a short timeout (five seconds or less) and drop connections that do not authenticate in time.
Authorization per Message
Authentication confirms who the client is. Authorization determines what the client can do. Do not assume that an authenticated connection has permission to perform every action.
Validate each incoming message against the user’s permissions:
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'read_channel':
if (!user.canRead(msg.channelId)) {
ws.send(JSON.stringify({ error: 'Forbidden' }));
return;
}
subscribeToChannel(ws, msg.channelId);
break;
case 'admin_action':
if (!user.isAdmin()) {
ws.send(JSON.stringify({ error: 'Forbidden' }));
ws.close(4003, 'Unauthorized action');
return;
}
handleAdminAction(ws, msg);
break;
}
});
A connected user might attempt to access channels they do not belong to, perform admin actions, or read other users’ data. Your WebSocket handler must enforce the same authorization rules as your REST API. If a user’s permissions change during an active session (for example, they are removed from a group), revoke access on the open connection immediately.
Input Validation
Treat every WebSocket message as untrusted input, exactly the way you treat HTTP request bodies. An attacker with a WebSocket client can send any string, binary data, or malformed JSON they want.
Validate the structure and content of every message:
const Ajv = require('ajv');
const ajv = new Ajv();
const messageSchema = {
type: 'object',
properties: {
type: { type: 'string', enum: ['chat', 'typing', 'read_receipt'] },
channelId: { type: 'string', pattern: '^[a-zA-Z0-9_-]+$' },
content: { type: 'string', maxLength: 2000 },
},
required: ['type'],
additionalProperties: false,
};
const validate = ajv.compile(messageSchema);
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
ws.close(4000, 'Invalid JSON');
return;
}
if (!validate(msg)) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
return;
}
handleValidatedMessage(ws, msg);
});
Key validation rules:
- Reject messages that do not parse as valid JSON (or your chosen format).
- Enforce maximum lengths on all string fields to prevent memory exhaustion.
- Restrict field values to expected types and patterns.
- Strip or reject unexpected properties with
additionalProperties: false. - Sanitize any data that will be inserted into a database query or rendered in the DOM.
Rate Limiting
Without rate limiting, a single client can flood your server with thousands of messages per second. Implement rate limits at multiple levels.
Here is a per-connection message rate limiter:
function createRateLimiter(maxMessages, windowMs) {
const timestamps = [];
return function isAllowed() {
const now = Date.now();
const windowStart = now - windowMs;
// Remove timestamps outside the window
while (timestamps.length > 0 && timestamps[0] < windowStart) {
timestamps.shift();
}
if (timestamps.length >= maxMessages) {
return false;
}
timestamps.push(now);
return true;
};
}
wss.on('connection', (ws) => {
const rateLimiter = createRateLimiter(30, 10000); // 30 messages per 10 seconds
let violations = 0;
ws.on('message', (data) => {
if (!rateLimiter()) {
violations++;
ws.send(JSON.stringify({ error: 'Rate limit exceeded' }));
if (violations > 3) {
ws.close(4008, 'Too many requests');
}
return;
}
handleMessage(ws, data);
});
});
Apply these additional limits:
- Per-IP connection limit: Restrict the number of simultaneous WebSocket connections from a single IP address. Five to ten connections per IP is reasonable for most applications.
- Payload size limit: Set a maximum frame size. The
wslibrary accepts amaxPayloadoption (in bytes). Reject frames that exceed your expected maximum. - Backpressure handling: If your server cannot process messages as fast as the client sends them, buffer or drop messages rather than letting memory grow without limit.
const wss = new WebSocketServer({
port: 8080,
maxPayload: 64 * 1024, // 64 KB max message size
});
Denial of Service Protection
WebSocket connections are persistent, which makes them a target for resource exhaustion attacks. A slow client can hold a connection open indefinitely, consuming server memory and file descriptors.
Protect against these attacks:
- Connection limits: Set a hard cap on total simultaneous connections. When the limit is reached, reject new connections with a 503 status during the handshake.
- Idle timeouts: Close connections that have not sent or received data within a timeout period. For most applications, 30 to 60 seconds of inactivity is a reasonable threshold.
- Ping/pong for dead connections: The WebSocket protocol includes ping and pong control frames. Send periodic pings and close connections that do not respond with a pong within a few seconds. This detects dead connections caused by network failures, crashed clients, or half-open TCP connections.
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const PONG_TIMEOUT = 10000; // 10 seconds
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
- Slowloris-style attacks: An attacker can open many connections and send data very slowly, one byte at a time, to tie up server resources. Set timeouts on the HTTP upgrade handshake and enforce minimum data rates on established connections.
Cross-Site WebSocket Hijacking (CSWSH)
Cross-site WebSocket hijacking is the WebSocket equivalent of Cross-Site Request Forgery (CSRF). It works like this:
- A user logs into your application at
https://yourapp.comand receives a session cookie. - The user visits a malicious site at
https://evil.com. - JavaScript on
evil.comopens a WebSocket connection towss://yourapp.com/ws. - The browser attaches the session cookie to the upgrade request automatically.
- If your server only checks the cookie and not the origin, it accepts the connection.
- The attacker’s script can now send and receive messages on the user’s behalf.
The fix is straightforward: validate the Origin header during the handshake. If the origin does not match your allowed list, reject the connection. This is why origin validation and authentication are both necessary. The cookie proves the user’s identity, but the origin proves the request came from your site, not from an attacker’s page. WebSocket pentesting tools often target this vulnerability specifically, making proper origin validation essential.
CSWSH is different from CSRF in one important way. With CSRF, the attacker submits a single forged request. With CSWSH, the attacker gains a persistent, bidirectional channel. They can exfiltrate data over time, send multiple commands, and maintain access as long as the connection stays open. The impact is typically more severe than a single forged request.
Common Vulnerabilities
Even with proper transport security, authentication, and origin validation, your application can still be vulnerable if you mishandle WebSocket data.
XSS Through WebSocket Data
If you display WebSocket messages in the DOM without sanitizing them, an attacker can inject scripts through the WebSocket channel. This is especially dangerous in chat applications and collaborative tools where messages from one user are rendered in another user’s browser.
Always sanitize WebSocket data before inserting it into the DOM. Use textContent instead of innerHTML, or use a sanitization library like DOMPurify. For more on how WebSocket data flows between clients, review the protocol fundamentals.
SQL Injection Through WebSocket Messages
If your server builds SQL queries using data from WebSocket messages without parameterized queries, attackers can inject SQL. This is the same vulnerability as SQL injection in HTTP endpoints, but developers sometimes forget to apply the same protections to WebSocket handlers.
Use parameterized queries or an ORM for all database operations, regardless of whether the input comes from HTTP or WebSocket.
Prototype Pollution with JSON.parse
When you call JSON.parse() on a WebSocket message, the resulting object can contain __proto__, constructor, or prototype properties. If you merge this object into another object using spread operators or Object.assign, an attacker can pollute the prototype chain and modify the behavior of your application.
Validate incoming JSON against a strict schema that rejects unexpected properties. Alternatively, use Object.create(null) as the base for objects that receive untrusted data.
Security Checklist
Before deploying a WebSocket server to production, verify the following:
- All connections use
wss://with valid TLS certificates. - The server validates the
Originheader during the handshake. - Every connection is authenticated before receiving application messages.
- Each message is authorized against the user’s current permissions.
- All incoming message data is validated against a schema.
- String fields have maximum length limits.
- Message rate limits are enforced per connection.
- Per-IP connection limits are in place.
- Maximum payload size is configured.
- Idle connections are closed after a timeout period.
- Ping/pong heartbeats detect and clean up dead connections.
- A hard cap on total simultaneous connections exists.
- WebSocket data is sanitized before DOM insertion on the client.
- Database queries use parameterized statements for WebSocket-sourced data.
- Authentication tokens are short-lived and rotated regularly.
- Server access logs do not contain sensitive tokens from query strings.
- Error messages sent to clients do not expose internal server details.
If you want to test your WebSocket server against these requirements, use a WebSocket testing tool to send crafted frames and verify your server’s responses.
What to Read Next
- WebSocket Authentication for a deeper look at token strategies and session management.
- The WebSocket Handshake to understand how the HTTP upgrade process works and where to enforce security checks.
- What is WebSocket? for protocol fundamentals if you are new to WebSocket development.
- Building a WebSocket Server with Node.js for a practical server implementation that includes security measures.
- WebSocket Tester to inspect and debug your WebSocket connections interactively.