WebSocket authentication: passing a token without cookies.
WebSocket connections don't support Authorization headers in the browser. The patterns for authenticating WebSocket connections -- query params, first-message auth, and cookie-based approaches.
The problem with WebSocket auth
HTTP requests can carry an Authorization: Bearer <token> header. WebSocket connections start as HTTP upgrade requests, so you might expect the same to work. The limitation: the browser’s WebSocket API doesn’t allow setting custom headers.
// This is NOT possible in browser WebSockets
const ws = new WebSocket('wss://api.example.com', {
headers: { Authorization: 'Bearer my-token' } // doesn't work
});
This forces different authentication patterns for WebSocket connections.
Option 1: token in the URL query string
The simplest approach — include the token in the URL:
const token = getAuthToken();
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
On the server, read it from the upgrade request URL:
const { WebSocketServer } = require('ws');
const url = require('url');
const jwt = require('jsonwebtoken');
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const { query } = url.parse(request.url, true);
const token = query.token;
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
// Attach user to request for later access
request.user = user;
wss.handleUpgrade(request, socket, head, ws => {
wss.emit('connection', ws, request);
});
} catch (err) {
socket.destroy(); // reject the connection
}
});
Drawback: tokens in URLs appear in server logs, browser history, and web server access logs. This is a real security concern for long-lived tokens. Mitigate with short-lived tokens (30-60 seconds) generated specifically for the WebSocket connection.
Option 2: first-message authentication
Establish the connection unauthenticated, then require the client to send credentials as the first message. Reject the connection if the first message isn’t valid authentication.
// Client
const ws = new WebSocket('wss://api.example.com/ws');
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token: getAuthToken() }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_ok') {
// Connection is now authenticated, proceed
setupMessageHandlers();
} else if (msg.type === 'auth_fail') {
ws.close();
}
};
// Server
wss.on('connection', (ws) => {
let authenticated = false;
let authTimeout;
// Give the client 5 seconds to authenticate
authTimeout = setTimeout(() => {
if (!authenticated) {
ws.close(4001, 'Authentication timeout');
}
}, 5000);
ws.on('message', async (data) => {
const message = JSON.parse(data);
if (!authenticated) {
if (message.type === 'auth') {
try {
const user = jwt.verify(message.token, process.env.JWT_SECRET);
authenticated = true;
clearTimeout(authTimeout);
ws.user = user;
ws.send(JSON.stringify({ type: 'auth_ok', userId: user.id }));
} catch {
ws.send(JSON.stringify({ type: 'auth_fail' }));
ws.close(4001, 'Authentication failed');
}
}
return; // ignore all other messages until authenticated
}
handleMessage(ws, message);
});
});
Advantage: the token never appears in logs. Drawback: slightly more complex, and there’s a brief window where the connection exists but isn’t authenticated.
Option 3: cookies
Cookies are sent automatically with the WebSocket upgrade request — the browser includes them just as it would with a regular HTTP request.
// Client: no extra work needed, cookie is sent automatically
const ws = new WebSocket('wss://api.example.com/ws');
// Server: read the cookie from the upgrade request
server.on('upgrade', (request, socket, head) => {
const cookies = parseCookies(request.headers.cookie);
const sessionToken = cookies['session'];
// validate and proceed
});
Advantage: no client-side token handling, works with session-based auth. Drawback: requires cookies to be set on the same domain, HttpOnly cookies are readable by the server but the client can’t read them to include in URL params — this is actually a security advantage.
Recommended approach
For SPAs with JWT authentication: use short-lived tokens in query params, generated by a dedicated endpoint that issues a one-time-use WebSocket connection token. This keeps long-lived tokens out of logs.
For session-based applications: cookies are the cleanest solution.
For React Native (no browser, full control over headers): send the Authorization header directly, since the WebSocket implementation in React Native is not restricted like the browser’s.