WebSocket Authentication Methods
WebSocket has no built-in authentication mechanism. The protocol provides an upgrade handshake, but no standard way to verify who is connecting. You need to add authentication yourself, and there are several practical approaches.
This guide covers four methods for authenticating WebSocket connections, each with working code, trade-offs, and guidance on when to pick one over another. Every method here has been used in production systems, and the right choice depends on your architecture and threat model.
Why WebSocket Auth Is Different from HTTP
If you have worked with REST APIs, you are used to sending an Authorization header with every request. The browser WebSocket API does not support custom headers. When you call new WebSocket('wss://example.com/ws'), the browser sends the HTTP upgrade request on your behalf, and you cannot attach headers like Authorization: Bearer <token>.
Here is what the browser does send automatically:
- Cookies for the target domain
- The
Originheader - Standard headers like
HostandSec-WebSocket-Key
That is it. No custom headers, no bearer tokens in headers, no basic auth from JavaScript. This constraint shapes every authentication strategy for browser-based WebSocket clients.
Non-browser clients (like a Node.js process or a mobile app) can set custom headers during the handshake. But if you need to support browsers, you need one of the four methods below. For more background on the handshake itself, see the WebSocket handshake guide.
Method 1: Token in Query String
The simplest approach is to append your authentication token to the WebSocket URL as a query parameter.
Client Code
const token = getAuthToken(); // your JWT or session token
const ws = new WebSocket(`wss://example.com/ws?token=${token}`);
ws.addEventListener('open', () => {
console.log('Connected and authenticated');
});
ws.addEventListener('close', (event) => {
if (event.code === 4001) {
console.log('Authentication failed');
}
});
Server Code (Node.js with ws)
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import jwt from 'jsonwebtoken';
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
wss.handleUpgrade(request, socket, head, (ws) => {
ws.user = user;
wss.emit('connection', ws, request);
});
} catch (err) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
}
});
wss.on('connection', (ws) => {
console.log(`User connected: ${ws.user.sub}`);
});
server.listen(8080);
The key detail here: authentication happens before the WebSocket upgrade completes. If the token is invalid, the server rejects the HTTP request and no WebSocket connection is established.
Pros
- Simple to implement on both client and server
- Works in every browser and every WebSocket library
- Authentication happens before the connection opens
Cons
- The token appears in the URL, which means it can be logged by proxies, load balancers, and web servers
- Browser history and developer tools will show the token
- Server access logs will contain the token
Mitigation
Use short-lived tokens (30 to 60 seconds) that are generated right before connecting. Single-use tokens are even better. If a logged token is discovered later, it will already be expired or consumed. Method 4 (ticket exchange) formalizes this pattern.
Method 2: Cookie-Based Authentication
If your application already uses cookie-based sessions (common with frameworks like Express, Django, or Rails), the browser will send those cookies during the WebSocket handshake automatically.
Client Code
// No special auth code needed. The browser sends cookies automatically.
const ws = new WebSocket('wss://example.com/ws');
ws.addEventListener('open', () => {
console.log('Connected with session cookie');
});
Server Code (Node.js with Express Session)
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import express from 'express';
import session from 'express-session';
import cookie from 'cookie';
import cookieSignature from 'cookie-signature';
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });
const sessionStore = new session.MemoryStore();
const SESSION_SECRET = process.env.SESSION_SECRET;
app.use(session({
store: sessionStore,
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
server.on('upgrade', (request, socket, head) => {
// Validate Origin header to prevent Cross-Site WebSocket Hijacking
const origin = request.headers.origin;
if (origin !== 'https://example.com') {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// Parse the session cookie
const cookies = cookie.parse(request.headers.cookie || '');
const sid = cookies['connect.sid'];
if (!sid) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Unsign the session ID
const sessionId = cookieSignature.unsign(sid.slice(2), SESSION_SECRET);
if (!sessionId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Look up the session
sessionStore.get(sessionId, (err, sessionData) => {
if (err || !sessionData || !sessionData.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
ws.userId = sessionData.userId;
wss.emit('connection', ws, request);
});
});
});
server.listen(8080);
The Origin Check Is Mandatory
Without the Origin header check, your WebSocket endpoint is vulnerable to Cross-Site WebSocket Hijacking (CSWSH). Here is what happens without it:
- A user is logged into your site and has a valid session cookie
- The user visits a malicious site
- The malicious site opens a WebSocket to your server:
new WebSocket('wss://yoursite.com/ws') - The browser sends the user’s session cookie automatically
- Your server accepts the connection, thinking the user initiated it
The Origin check prevents this because the malicious site’s origin will not match your allowed origins.
Pros
- Zero client-side authentication code
- Works with existing session infrastructure
- Familiar pattern for teams already using cookie-based auth
Cons
- Vulnerable to CSWSH if you skip the Origin check
- Cookies are domain-bound, making cross-domain connections difficult
- Does not work well for non-browser clients
Method 3: First Message Authentication
With this approach, the WebSocket connection opens without authentication. The client immediately sends an authentication message, and the server validates it before processing any other messages.
Client Code
const ws = new WebSocket('wss://example.com/ws');
ws.addEventListener('open', () => {
// Send auth as the very first message
ws.send(JSON.stringify({
type: 'auth',
token: getAuthToken()
}));
});
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_success') {
console.log('Authenticated, ready to send messages');
}
if (msg.type === 'auth_failed') {
console.log('Authentication failed');
ws.close();
}
});
Server Code
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
ws.isAuthenticated = false;
ws.messageQueue = [];
// Set a timeout: if no auth within 5 seconds, disconnect
const authTimeout = setTimeout(() => {
if (!ws.isAuthenticated) {
ws.send(JSON.stringify({ type: 'auth_failed', reason: 'timeout' }));
ws.close(4001, 'Authentication timeout');
}
}, 5000);
ws.on('message', (data) => {
const msg = JSON.parse(data);
// Handle auth message
if (!ws.isAuthenticated) {
if (msg.type !== 'auth') {
// Queue messages received before auth, or reject them
ws.send(JSON.stringify({ type: 'error', reason: 'Not authenticated' }));
return;
}
try {
const user = jwt.verify(msg.token, process.env.JWT_SECRET);
ws.isAuthenticated = true;
ws.user = user;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_success', userId: user.sub }));
} catch (err) {
ws.send(JSON.stringify({ type: 'auth_failed', reason: 'Invalid token' }));
ws.close(4001, 'Invalid token');
}
return;
}
// Handle normal messages after authentication
handleMessage(ws, msg);
});
});
function handleMessage(ws, msg) {
// Your application logic here
console.log(`Message from ${ws.user.sub}:`, msg);
}
Pros
- Clean URLs with no tokens in query strings
- No dependency on cookies
- Works well for both browser and non-browser clients
- Flexible, as you can pass any auth data in the message
Cons
- The connection exists before authentication completes, consuming server resources
- You must implement a timeout to close unauthenticated connections
- Every message handler needs to check authentication state
- Slightly more code on both client and server
This method is popular with JavaScript WebSocket clients because it keeps the connection URL clean and works naturally with token-based auth systems.
Method 4: Ticket/Token Exchange (Two-Step)
This is the most secure browser-compatible method. The client first makes an authenticated HTTP request to get a one-time ticket, then uses that ticket to open the WebSocket connection.
Flow
Client Server
| |
|-- POST /api/ws-ticket ------->| (with Authorization header)
|<-- { ticket: "abc123" } ------| (one-time ticket, expires in 30s)
| |
|-- WS wss://example.com/ws?ticket=abc123 -->|
|<-- Connection established ----| (ticket consumed, cannot be reused)
Server Code: Ticket Endpoint
import express from 'express';
import crypto from 'crypto';
const app = express();
const ticketStore = new Map();
// Authenticated HTTP endpoint to issue tickets
app.post('/api/ws-ticket', authenticateHTTP, (req, res) => {
const ticket = crypto.randomBytes(32).toString('hex');
ticketStore.set(ticket, {
userId: req.user.id,
roles: req.user.roles,
createdAt: Date.now(),
});
// Expire the ticket after 30 seconds
setTimeout(() => ticketStore.delete(ticket), 30000);
res.json({ ticket });
});
function authenticateHTTP(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
req.user = jwt.verify(authHeader.split(' ')[1], process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
Server Code: WebSocket Upgrade
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const ticket = url.searchParams.get('ticket');
if (!ticket || !ticketStore.has(ticket)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Consume the ticket (single use)
const userData = ticketStore.get(ticket);
ticketStore.delete(ticket);
wss.handleUpgrade(request, socket, head, (ws) => {
ws.user = userData;
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws) => {
console.log(`Authenticated user: ${ws.user.userId}`);
});
server.listen(8080);
Client Code
async function connectWebSocket() {
// Step 1: Get a one-time ticket via authenticated HTTP
const response = await fetch('https://example.com/api/ws-ticket', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getAuthToken()}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to get WebSocket ticket');
}
const { ticket } = await response.json();
// Step 2: Connect with the ticket
const ws = new WebSocket(`wss://example.com/ws?ticket=${ticket}`);
ws.addEventListener('open', () => {
console.log('Connected and authenticated via ticket');
});
return ws;
}
Pros
- The real auth token never appears in WebSocket URLs or logs
- Tickets are single-use, so a logged ticket cannot be replayed
- Tickets expire quickly, limiting the attack window
- Authentication uses the standard HTTP
Authorizationheader
Cons
- Requires an extra HTTP request before each WebSocket connection
- More code to implement and maintain
- The ticket store needs cleanup logic (or use Redis with TTL)
Comparison Table
| Method | Security | Complexity | Browser Support | Non-Browser Support |
|---|---|---|---|---|
| Query String Token | Low to Medium | Low | Yes | Yes |
| Cookie-Based | Medium (with Origin check) | Medium | Yes | Limited |
| First Message | Medium | Medium | Yes | Yes |
| Ticket Exchange | High | High | Yes | Yes |
For most applications, ticket exchange provides the best security. If you are building an internal tool or a prototype, query string tokens get you running quickly. If you already have cookie-based sessions, cookie-based auth adds WebSocket support with minimal changes. For details on other security considerations, check the WebSocket security guide.
Refreshing Authentication
WebSocket connections can stay open for hours or days. Tokens expire. You need a strategy for handling authentication expiration on long-lived connections.
Server-Initiated Disconnect
The server tracks token expiration and sends a message before closing:
wss.on('connection', (ws) => {
const tokenExp = ws.user.exp * 1000; // JWT exp is in seconds
const timeUntilExpiry = tokenExp - Date.now();
// Warn the client 60 seconds before expiration
const warnTimer = setTimeout(() => {
ws.send(JSON.stringify({
type: 'auth_expiring',
expiresIn: 60,
}));
}, timeUntilExpiry - 60000);
// Close the connection when the token expires
const closeTimer = setTimeout(() => {
ws.close(4002, 'Token expired');
}, timeUntilExpiry);
ws.on('close', () => {
clearTimeout(warnTimer);
clearTimeout(closeTimer);
});
});
Client Reconnect Strategy
The client listens for the expiration warning and reconnects with a fresh token:
function createConnection() {
const ws = new WebSocket(`wss://example.com/ws?token=${getAuthToken()}`);
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_expiring') {
// Refresh the auth token
refreshAuthToken().then(() => {
// Reconnect with the new token
ws.close(1000, 'Reconnecting with fresh token');
createConnection();
});
}
});
ws.addEventListener('close', (event) => {
if (event.code === 4002) {
// Token expired, get a new one and reconnect
refreshAuthToken().then(() => createConnection());
}
});
return ws;
}
A common pattern is to refresh the token proactively and reconnect before the server forces a disconnect. This avoids any gap in connectivity.
Authorization After Authentication
Authentication answers “who are you?” Authorization answers “what are you allowed to do?” After a WebSocket connection is authenticated, you often need per-action or per-channel permissions.
Per-Channel Authorization
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type === 'subscribe') {
// Check if the user can access this channel
if (!canAccessChannel(ws.user, msg.channel)) {
ws.send(JSON.stringify({
type: 'error',
reason: `Not authorized for channel: ${msg.channel}`,
}));
return;
}
addToChannel(ws, msg.channel);
ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
}
if (msg.type === 'publish') {
// Check if the user can publish to this channel
if (!canPublishToChannel(ws.user, msg.channel)) {
ws.send(JSON.stringify({
type: 'error',
reason: `Not authorized to publish to: ${msg.channel}`,
}));
return;
}
broadcastToChannel(msg.channel, msg.data, ws);
}
});
});
function canAccessChannel(user, channel) {
// Example: admins can access everything, users can access their own channels
if (user.roles.includes('admin')) return true;
if (channel.startsWith(`user:${user.id}:`)) return true;
if (channel.startsWith('public:')) return true;
return false;
}
function canPublishToChannel(user, channel) {
if (user.roles.includes('admin')) return true;
if (channel.startsWith(`user:${user.id}:`)) return true;
return false;
}
Authorization checks should run on every action, not just at connection time. A user’s permissions can change during a long-lived connection. For a deeper look at building a Node.js WebSocket server with these patterns, see the Node.js ws guide.
What to Read Next
- WebSocket Security covers encryption, input validation, rate limiting, and other security topics beyond authentication
- WebSocket Handshake explains the upgrade process in detail, which is where most authentication methods hook in
- Node.js ws Guide walks through building a production WebSocket server with the ws library
- JavaScript WebSocket Guide covers the browser WebSocket API, including reconnection and error handling