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.