WebSocket Authentication Deep Dive — Tokens, Stateful Connections, and the CORS Bypass Nobody Warns You About
WebSockets are powerful. They enable real-time, bidirectional communication between a client and a server — perfect for chat apps, live dashboards, collaborative tools, and more. But when it comes to authentication, they behave quite differently from regular HTTP requests, and those differences can introduce subtle security vulnerabilities if you're not careful.
In this post, I'll walk through:
- The three main ways to authenticate HTTP requests
- Why some of them simply don't work for WebSockets
- The right approach for WebSocket authentication
- A sneaky CORS bypass that most tutorials completely skip over
I'll be using the ws library for backend examples. If you're using socket.io, the abstraction layer handles some of this, but the core concepts — and the vulnerabilities — remain the same.
How Do We Authenticate Regular HTTP Requests?
Before jumping to WebSockets, let's recap the three standard ways to pass authentication tokens in a regular HTTP request.
1. Cookies
Cookies are one of the most secure options. You can store JWT tokens in HttpOnly cookies, which means JavaScript on the page can't access them, offering solid protection against XSS attacks.
The trade-off: Cookies aren't cache-friendly. CDN services like Cloudflare typically cache responses based on the URL, not cookie content. This can cause caching issues in some architectures.
2. URL Parameters (Path & Query)
Tokens can travel through the URL itself — as either a path parameter or a query parameter.
# Path parameter
GET https://api.example.com/data/user123
# Query parameter
GET https://api.example.com/data?token=abc123&filter=active
Path parameters are typically used for resource identification and content segregation. Query parameters are more flexible and can carry many conditional fields.
Both are part of the URL, which means they can show up in browser history, server logs, and CDN caches — making them less ideal for sensitive tokens in general HTTP contexts.
3. Authorization Header
The most widely used approach for API authentication. You attach a header directly to the request:
GET /api/resource HTTP/1.1
Host: yourserver.com
Authorization: Bearer <your_jwt_token>
This is clean, not part of the URL, and supported by every auth service and framework you'll encounter — Clerk, Auth0, Firebase Auth, custom JWT setups, you name it.
How Does a WebSocket Connection Work?
A WebSocket connection doesn't start from scratch — it upgrades an existing HTTP connection. Here's the flow:
- The client (browser) sends a special HTTP request with an
Upgrade: websocketheader. - The server recognizes the request and, if it accepts, responds with a
101 Switching Protocolsstatus code. - The TCP connection stays open and both client and server can now exchange messages freely and efficiently.
Client Server
| |
| GET /ws HTTP/1.1 |
| Upgrade: websocket |
| Connection: Upgrade |
| Sec-WebSocket-Key: xyz... |
|---------------------------------> |
| |
| HTTP/1.1 101 Switching Protocols |
| Upgrade: websocket |
| Connection: Upgrade |
| <---------------------------------|
| |
| <==== WebSocket frames ====> |
One critical property of WebSockets: they are stateful. Unlike HTTP — where the server forgets you the moment the response is sent — a WebSocket connection remembers who you are for the entire duration of the connection. This has major implications for authentication.
The WebSocket Authentication Problem
Here's where things get tricky. The browser's native WebSocket API — the one your frontend uses to initiate a connection — is quite restrictive:
You cannot add custom headers or a body to the WebSocket handshake request.
This is a browser-level limitation. When you call new WebSocket(url), the browser constructs and sends the upgrade request on your behalf, and it does not expose a way to inject custom headers.
This immediately rules out the Authorization header approach.
// ❌ This does NOT work in a browser
const ws = new WebSocket('wss://yourserver.com/ws', {
headers: {
Authorization: 'Bearer your_token_here' // Browsers ignore this
}
});
So where does that leave us? We're back to cookies or URL parameters. Both work — cookies are sent automatically with the upgrade request since it's still an HTTP request underneath. But for simplicity and flexibility (especially when working with JWTs from services like Clerk), passing the token as a query parameter is the most practical and widely adopted approach.
The Solution: Token as a Query Parameter
Frontend
// Get the token from your auth provider or local storage
const token = localStorage.getItem('auth_token');
// Pass it as a query parameter in the WebSocket URL
const ws = new WebSocket(`wss://yourserver.com/ws?token=${token}`);
ws.onopen = () => {
console.log('WebSocket connection established');
};
ws.onmessage = (event) => {
console.log('Message received:', event.data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
Backend (using the ws library)
On the server, you extract the token from the URL during the initial connection event, verify it, and attach the user to the socket instance.
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const url = require('url');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => {
// Step 1: Extract token from query params
const params = new URLSearchParams(url.parse(req.url).query);
const token = params.get('token');
if (!token) {
ws.close(4001, 'Unauthorized: No token provided');
return;
}
// Step 2: Verify the token
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Step 3: Attach the verified user to the socket
ws.user = decoded;
console.log(`User ${decoded.userId} connected`);
} catch (err) {
ws.close(4001, 'Unauthorized: Invalid or expired token');
return;
}
// Step 4: Handle messages — no re-authentication needed
ws.on('message', (message) => {
console.log(`Message from ${ws.user.userId}:`, message.toString());
// ws.user is already verified and available here
});
ws.on('close', () => {
console.log(`User ${ws.user?.userId} disconnected`);
});
});
Authentication Is Done Once — And That's by Design
Because WebSockets are stateful, authentication only happens once — at handshake time. The server verifies the token, attaches the user to the socket object, and then remembers that user for every message that follows. You do not need to re-authenticate on each message.
Client Server
| |
| WS Handshake + ?token=abc123 |
| ✅ Token verified |
| ws.user = { id: 42, role: 'user'}|
| <---------------------------------|
| |
| "message: Hello" ------------> | ← No re-auth needed
| "message: How are you?" ------> | ← ws.user still attached
| "message: Update this" -------> | ← ws.user still attached
This works regardless of which auth provider you're using. The pattern is always the same: validate once, trust the session.
⚠️ Token Expiry Note: If your JWT can expire during an active WebSocket session, you'll need a strategy for that. Options include: closing the connection when the token expires (and letting the client reconnect with a fresh one), or implementing a heartbeat mechanism where the client periodically sends a refreshed token over the socket.
The Hidden CORS Bypass — A Real Security Risk
This is the part most tutorials skip — and it's arguably the most important.
WebSocket upgrade requests do NOT go through your cors() middleware.
That cors() call in your Express app? The one that blocks requests from unknown origins? It does not apply to WebSocket connections. The WebSocket upgrade happens at the raw HTTP server level, before Express processes it.
// ✅ This protects your REST API routes
app.use(cors({ origin: 'https://yourfrontend.com' }));
// ❌ This does NOT protect your WebSocket endpoint
wss.on('connection', (ws, req) => { ... });
What this means in practice: an attacker could write a script (not even a browser, just a raw Node.js/Python script) that establishes a WebSocket connection to your server from any origin — completely bypassing your CORS policy. If your only gating mechanism was origin-based, your server is open.
The Fix: Manually Validate the Origin Header
You need to manually check the origin header on every incoming WebSocket connection.
const ALLOWED_ORIGINS = [
'https://yourfrontend.com',
'http://localhost:3000' // for local development
];
wss.on('connection', (ws, req) => {
const origin = req.headers['origin'];
// 1. Manual origin check — CORS middleware won't do this for you
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
console.warn(`Rejected WebSocket connection from origin: ${origin}`);
ws.close(4003, 'Forbidden: Invalid origin');
return;
}
// 2. Token verification
const params = new URLSearchParams(url.parse(req.url).query);
const token = params.get('token');
if (!token) {
ws.close(4001, 'Unauthorized: No token provided');
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
ws.user = decoded;
} catch (err) {
ws.close(4001, 'Unauthorized: Invalid token');
return;
}
ws.on('message', (message) => {
console.log(`Message from ${ws.user.userId}:`, message.toString());
});
});
For an even cleaner approach, you can intercept the upgrade at the raw HTTP server level — rejecting invalid connections before they even become WebSocket connections:
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer(app); // your Express app
const wss = new WebSocket.Server({ noServer: true });
// Intercept the HTTP upgrade request before it becomes a WebSocket
server.on('upgrade', (req, socket, head) => {
const origin = req.headers['origin'];
if (!ALLOWED_ORIGINS.includes(origin)) {
// Reject at the HTTP level — clean and early
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
server.listen(8080);
This is the cleanest pattern — invalid origins are rejected at the TCP socket level before any WebSocket logic runs.
Summary
| Auth Method | Works for WebSockets? | Notes |
|---|---|---|
| Authorization Header | ❌ No | Browser WebSocket API doesn't allow custom headers |
| Cookies | ✅ Yes | Sent automatically; watch out for CSRF |
| Query Parameters | ✅ Yes | Most practical; token visible in URL/logs |
| Path Parameters | ⚠️ Awkward | Not conventional for auth tokens |
Here are the five things to walk away with:
- You cannot add custom headers to a WebSocket handshake from the browser — the native WebSocket API doesn't allow it, so the Authorization header approach is out.
- Pass the token as a query parameter in the WebSocket URL — it's the most practical and widely adopted approach.
- Authenticate once, at handshake time — because WebSockets are stateful, the server remembers the authenticated user. There's no need to re-authenticate on each message.
- Your initial auth must be airtight — since it's the only time authentication happens on that connection, make sure the token validation is solid.
-
WebSocket requests bypass your
cors()middleware — you must manually validate theOriginheader on the server to prevent connections from unauthorized origins.
If you're using socket.io instead of the raw ws library, some of this is handled by the abstraction — but the browser still uses the native WebSocket API underneath, so the CORS bypass and the query-param pattern still apply at the core level.
Happy coding! 🚀
Top comments (0)