If you've ever built a real-time feature with WebSockets, you've probably hit the same roadblock almost immediately: how do I securely authenticate the connection?
With a normal HTTP request you slap an
Authorization: Bearer <JWT> header onto every
call and move on. The browser's native
WebSocket API doesn't let you add custom headers to
the initial handshake. That single limitation has pushed
developers to get creative — sometimes too creative.
Below are the three patterns that emerged, and why enterprises like Slack, Discord, and AWS converge on one of them: the token exchange, or “ticket,” pattern.
The core problem: the blind spot of new WebSocket()
When you run this line in the browser:
const socket = new WebSocket('wss://example.com/feed'); you're initiating an HTTP Upgrade request. But because the API won't let you modify the headers of that request, you can't pass the user's JWT the standard way. Three workarounds followed.
Solution 1: send the token as the “first message”
Open an unauthenticated connection, let the server accept it, then send the JWT as the very first data frame.
const ws = new WebSocket('wss://example.com/feed');
ws.onopen = () => {
// The very first message handles authentication
ws.send(JSON.stringify({ type: 'auth', token: userJwtToken }));
}; - The good: trivial to implement, and it fits naturally with framework ecosystems (Socket.io, GraphQL subscriptions) already built around JSON messaging. No header size limits to worry about.
- The bad: your server must accept connections from unauthenticated — possibly malicious — clients. Open thousands of silent sockets that never send an auth message and you exhaust memory and file descriptors. You end up writing aggressive timeout logic to kill connections that don't authenticate within a few seconds.
Solution 2: the subprotocol hack
The protocol supports a Sec-WebSocket-Protocol
header for negotiating the application language (e.g.
'graphql-ws'). Browsers do let you pass an
array of subprotocols — so some developers smuggle the JWT in
there.
// Passing the JWT inside the subprotocol array
const ws = new WebSocket('wss://example.com/feed', ['binary', 'jwt.' + userJwtToken]); - The good: the handshake runs over
wss://(TLS), so the token is safe in transit and stays out of URL logs. - The bad: it abuses the protocol's intent. Worse,
JWTs get large once they carry custom claims or roles, and HTTP
headers have strict size limits (often 4KB–8KB). If the token is
too big, a reverse proxy like Nginx or Cloudflare silently
rejects the connection with
431 Request Header Fields Too Large— a nightmare to debug.
Solution 3: the “ticket” (token exchange) pattern
Look under the hood of major real-time platforms and they use neither hack. They decouple authentication from the WebSocket connection entirely with a ticket exchange.
[ Client ] -- 1. POST /auth/ticket (with JWT) --> [ HTTP Server ]
[ Client ] <- 2. Returns short-lived ticket ----- [ HTTP Server ]
|
+--------- 3. wss://ws.example.com?ticket=123 --> [ WS Gateway ]
|
4. Verifies & burns ticket 1. Request a ticket
Before opening the socket, the client makes a normal HTTP
POST or GET to your API. Because it's a
standard request, you pass the long-lived JWT in the proper
Authorization: Bearer header.
2. Issue the ticket
The server verifies the JWT and, if valid, generates a short-lived, random, single-use string — the ticket. It's stored in a fast cache (like Redis), mapped to the user's ID, and set to expire in 10 to 30 seconds. The server returns it to the client.
3. Connect via the ticket
The client immediately opens the WebSocket, passing the ticket as a URL query parameter:
const ticket = await fetchTicket();
const ws = new WebSocket(`wss://example.com/feed?ticket=${ticket}`); 4. Burn the ticket
The gateway reads the ticket from the URL, checks the cache, matches it to the user, and immediately deletes (burns) it. The connection upgrades, and the ticket can never be used again.
Why the ticket pattern wins
- Gatekeeping at the door. Your WebSocket server never maintains or tolerates unauthenticated connections. Without a valid, fresh ticket, a bad actor is rejected at the HTTP handshake — before any socket resources are allocated.
- Zero log-leakage risk. The usual objection to tokens in a URL is that proxies log URLs in plain text. But a ticket expires in seconds and is deleted the moment it's used — a leaked ticket in a log file is useless to an attacker.
- No header size limits. You're not stuffing a claim-heavy JWT into the handshake. The ticket is a tiny string.
- Scalability. The gateway doesn't verify complex JWT signatures or hit a database. It just checks a fast key-value store to see if the ticket exists.
If you're building a hobby app or using a framework that handles connection timeouts out of the box, the first-message approach is fine. But if you're designing to scale, handle heavy traffic, and meet strict security standards, do what the giants do: exchange your token for a ticket first.
Kirin builds and operates real-time and AI features for product teams who can't afford to ship vibes.