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.
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.
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.
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.
Related #
- Validating JWT on the WebSocket Upgrade — the token the ticket endpoint accepts.
- Cookie Session Authentication for WebSockets — the same-site alternative.
- Avoiding Reconnect Loops on Auth Failure — fetching a ticket per attempt.
- Enforcing Origin and CSRF Checks on WebSockets — the Origin binding used on redeem.