Security is the chapter most tutorials skip — and the one that bites you in production. A wide-open WebSocket server is a serious attack surface. Here are every threat and its defense.
ws:// sends everything as plaintext. Anyone on the same network (café WiFi, shared office) can read every message.
# nginx.conf — TLS termination for WebSocket
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/ssl/certs/example.crt;
ssl_certificate_key /etc/ssl/private/example.key;
location /ws {
proxy_pass <http://localhost:8080>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # Required for WS!
proxy_set_header Connection "upgrade"; # Required for WS!
proxy_set_header Host $host;
proxy_read_timeout 3600s; # Keep connection alive
}
}
Your Node app runs plain ws:// internally. Nginx handles the TLS and proxies to it.
Browser WebSocket API cannot set custom HTTP headers, so pass the JWT in the query string:
// client.js
const token = localStorage.getItem('jwt');
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
// server.js
const jwt = require('jsonwebtoken');
wss.on('connection', (ws, req) => {
const url = new URL(req.url, '<http://localhost>');
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'Missing authentication token');
return;
}
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
ws.user = user; // Attach user info to socket
console.log('Authenticated:', user.email);
} catch (err) {
// Token invalid or expired
ws.close(4001, 'Invalid or expired token');
return;
}
ws.on('message', (data) => {
// ws.user is always set — safe to use
console.log(`Message from ${ws.user.email}:`, data.toString());
});
});
Without this, any website can open a WebSocket to your server using the visitor's cookies:
// server.js
const ALLOWED_ORIGINS = [
'<https://myapp.com>',
'<https://www.myapp.com>',
'<http://localhost:5173>', // Vite dev server
'<http://localhost:3000>', // CRA dev server
];
const wss = new WebSocket.Server({
port: 8080,
verifyClient: ({ origin, req }, callback) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
callback(true); // ✅ Accept
} else {
console.warn(`Blocked connection from origin: ${origin}`);
callback(false, 403, 'Forbidden');
}
},
});
// server.js
const MAX_MESSAGES_PER_MINUTE = 100;
wss.on('connection', (ws) => {
ws.messageCount = 0;
ws.rateLimitReset = Date.now() + 60_000;
ws.on('message', (raw) => {
const now = Date.now();
// Reset counter every minute
if (now > ws.rateLimitReset) {
ws.messageCount = 0;
ws.rateLimitReset = now + 60_000;
}
ws.messageCount++;
if (ws.messageCount > MAX_MESSAGES_PER_MINUTE) {
ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
ws.close(4029, 'Too many messages');
return;
}
// Process message...
});
});