Authenticating WebSockets with short-lived tickets #

The browser WebSocket API cannot set an Authorization header, so many teams put the user’s access token in the query string: wss://rt.example.com/ws?token=eyJhbGci…. It works, and then the security review finds that token — valid for an hour — in the load balancer’s access logs, in the CDN’s request logs, in the APM tool’s captured URLs and in a support ticket’s screenshot. A URL is the most widely logged part of any request. The fix is to stop putting a reusable credential there. Instead, exchange the real credential for a ticket over a normal authenticated HTTP call: a random, single-use value that expires in seconds and is worthless by the time anyone reads a log.

Root cause #

Three constraints collide. Browsers only let you influence the WebSocket handshake through the URL, the Sec-WebSocket-Protocol header (via the protocols argument) and cookies. Cookies do not work for cross-site deployments or where third-party cookies are blocked, as covered in cookie session authentication for WebSockets. And the URL is logged by nearly every component on the path: reverse proxies, load balancers, CDNs, WAFs, tracing systems and browser history. Putting a bearer token with a lifetime of minutes or hours into that URL means anyone with log access can impersonate the user until it expires.

A ticket changes the property that matters. It is still in the URL, but it can be redeemed exactly once and only for a few seconds. A ticket found in a log has already been used, or has expired, or both.

Ticket issue and redeem The browser posts to an authenticated HTTP endpoint that stores a thirty-second ticket and returns it; the browser connects with the ticket, and the WebSocket server atomically fetches and deletes it before completing the upgrade. Ticket issue and redeem Browser HTTP API Ticket store WS server POST /ws-ticket (Authorization) store ticket → user, 30 s TTL { ticket } wss://…/ws?ticket=… GETDEL ticket (atomic) user id, ticket gone 101 Switching Protocols The access token never appears in a URL; the ticket that does is consumed on first use
Use the header-capable channel (fetch) to mint a credential the header-less channel (WebSocket) can safely carry.

Resolution #

Issue tickets from an authenticated HTTP endpoint and store them in Redis with a short TTL. Redeem them on the upgrade with an atomic get-and-delete, so two connections can never share one ticket. Bind the ticket to the origin it was issued for, and optionally to the client’s IP address, to narrow its usefulness further.

import { randomBytes } from 'node:crypto';
import http from 'node:http';
import { createClient } from 'redis';
import { WebSocketServer } from 'ws';

const TICKET_TTL_SECONDS = 30; // long enough for a slow mobile handshake, no longer
const TICKET_BYTES = 32; // 256 bits: unguessable
const redis = createClient();
await redis.connect();

interface TicketClaims { userId: string; origin: string; issuedAt: number }

// --- HTTP side: called with the user's normal Authorization header -------------
export async function issueTicket(userId: string, origin: string): Promise<string> {
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
const claims: TicketClaims = { userId, origin, issuedAt: Date.now() };
await redis.set(`wst:${ticket}`, JSON.stringify(claims), { EX: TICKET_TTL_SECONDS });
return ticket;
}

// --- WebSocket side ----------------------------------------------------------------
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://placeholder');
const ticket = url.searchParams.get('ticket');
// GETDEL is atomic: the first redeemer wins, any replay finds nothing.
const raw = ticket ? await redis.getDel(`wst:${ticket}`) : null;
const claims = raw ? (JSON.parse(raw) as TicketClaims) : null;

if (!claims || claims.origin !== req.headers.origin) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, claims.userId));
});

server.listen(8080);

On the client, fetch a fresh ticket before every connection attempt, including reconnects. That makes tickets naturally compatible with the retry logic in avoiding reconnect loops on auth failure: a rejected handshake costs one ticket, and the next attempt gets a new one with whatever credential refresh the HTTP layer performs.

async function connect(): Promise<WebSocket> {
const res = await fetch('/api/ws-ticket', { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(`ticket request failed: ${res.status}`);
const { ticket } = await res.json();
return new WebSocket(`wss://rt.example.com/ws?ticket=${encodeURIComponent(ticket)}`);
}

Stop the ticket from lingering in logs you control as well: configure your proxy’s access log format to omit the query string on the WebSocket location, or to redact the ticket parameter. The single-use property is the main defence, but there is no reason to keep even a spent credential.

Credential options for the browser handshake A comparison of access tokens in the query string, tokens smuggled in the subprotocol header, session cookies and single-use tickets for logging exposure, reuse after a leak and cross-site support. Credential options for the browser handshake Logged in URL? Reusable if leaked Cross-site Access token in query yes until expiry works Token in subprotocol header, less often until expiry works Session cookie no until logout unreliable Single-use ticket yes, but spent no works Tickets are the only option that stays safe when the URL leaks and still works cross-site
A ticket accepts that the URL will leak and makes the leak harmless.

Verification #

Test the single-use property directly — it is the whole point:

TICKET=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" https://api.example.com/ws-ticket | jq -r .ticket)
HDRS=(-H 'Connection: Upgrade' -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13'
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Origin: https://app.example.com')
curl -si --http1.1 "${HDRS[@]}" "https://rt.example.com/ws?ticket=$TICKET" --max-time 2 | head -1 # 101
curl -si --http1.1 "${HDRS[@]}" "https://rt.example.com/ws?ticket=$TICKET" --max-time 2 | head -1 # 401

Then test expiry: mint a ticket, wait longer than the TTL, and confirm the upgrade is rejected. Finally, grep your load balancer and CDN logs for ?token= to confirm no long-lived credential is still being sent anywhere after the migration.

A ticket's whole life A ticket is issued, redeemed 0.4 seconds later, and written to a log at two seconds; a replay at twenty seconds fails because the ticket was already consumed, and it would have expired at thirty seconds anyway. A ticket's whole life already consumed issued over HTTPS (0 s) redeemed on upgrade (0.4 s) URL written to access log (2 s) replayed from log (20 s) TTL would expire (30 s) Two independent defences: single use and a 30 s lifetime
By the time anyone can read the log line, the ticket is gone.

Operational checklist #

FAQ #

Why not pass the JWT in the Sec-WebSocket-Protocol header? #

It avoids URL logging but abuses a header meant for protocol negotiation: the server must echo a chosen value back, some proxies log the header, and the token remains reusable until expiry. A ticket is cleaner and does not depend on header handling.

Does the ticket need to be a JWT? #

No. An opaque random value looked up in a store is simpler and can be revoked by deleting it. A signed ticket avoids the store lookup but cannot be made single-use without a store anyway, which defeats the benefit.

What if Redis is unavailable? #

Handshakes fail closed, which is correct for an authentication dependency. Run the ticket store with the same availability as your session store; it holds tiny, short-lived keys, so a small replicated instance is enough.

Should the socket re-authenticate after the ticket is used? #

The ticket authenticates the connection once. For long-lived sockets, enforce session or token expiry on the open connection separately, as in rotating WebSocket tokens without dropping connections.

Back to WebSocket Authentication & Authorization.