Validating JWT on the WebSocket upgrade #

You want to reject an unauthenticated socket before it becomes a socket — not after connection fires and a hostile peer is already inside your event loop. The blocker is the browser API: new WebSocket(url) accepts no headers, so the standard Authorization: Bearer <jwt> you use everywhere else simply cannot be set. This page covers the three transports that actually work in a browser, where to validate the token during the HTTP upgrade, and how to handle a JWT that expires while the connection is still open.

Root cause #

The WebSocket constructor in browsers exposes exactly two arguments: url and an optional protocols string or array. There is no headers option, no fetch-style RequestInit, and the EventSource-style cookie behavior is your only “free” credential. This is a deliberate constraint in the WHATWG/RFC 6455 handshake: the upgrade is a real HTTP GET, but it is initiated by the user agent, not your JS, so you cannot decorate it the way XHR lets you.

That leaves three real options for getting a JWT to the server on the opening GET:

  1. httpOnly cookie — the browser attaches your session/JWT cookie automatically on a same-site upgrade. Best security posture (JS can’t read the token), but it is a cross-origin and CSRF-sensitive vector, so it must be paired with origin and CSRF checks.
  2. Short-lived ticket in the query stringwss://api/ws?ticket=<jwt>. The token rides the URL, so issue a separate, short-TTL (30–60 s) ticket JWT, never your long-lived session token, because URLs leak into proxy logs, access logs, and Referer-adjacent telemetry.
  3. Sec-WebSocket-Protocol header — the one header the constructor can influence, via the protocols argument: new WebSocket(url, ['jwt', token]). The server must echo one chosen subprotocol back, so you send a sentinel plus the token and select the sentinel.

The validation itself must happen at the HTTP layer, before the server emits 101 Switching Protocols. Once you return 101, you are speaking the WebSocket framing protocol — you can no longer send a clean HTTP 401, only a WebSocket close frame, which is a worse client experience and lets the TCP/TLS connection live longer than it should. Validate during the upgrade, reject before 101.

JWT validation on the WebSocket upgrade The browser sends an HTTP GET Upgrade carrying a JWT; the server verifies it and either replies 101 to accept or 401 to reject before the socket opens. Browser new WebSocket(url) Upgrade handler verify JWT GET Upgrade + token cookie / ticket / subprotocol valid: 101 Switching invalid: 401, no socket Decision happens before 101

Resolution #

The ws package gives you two hooks. verifyClient is the convenience path; a manual server.on('upgrade') handler is the explicit path and the one you want in production because it lets you parse all three transports, return a real 401, and decode claims once. The example below mounts a raw WebSocketServer({ noServer: true }) and drives the upgrade yourself.

import { createServer, IncomingMessage } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { parse as parseCookie } from 'cookie';
import { parse as parseUrl } from 'node:url';

const JWT_SECRET = process.env.JWT_SECRET!;
const TICKET_AUDIENCE = 'ws-ticket'; // tickets are minted with a distinct aud
const CLOCK_SKEW_SEC = 5; // tolerate small clock drift on exp/nbf

interface Claims extends JwtPayload { sub: string; exp: number; }

// noServer:true means ws never touches the listening socket itself —
// we decide per-request whether to complete the upgrade.
const wss = new WebSocketServer({ noServer: true });

// Pull a JWT out of whichever transport the client used.
function extractToken(req: IncomingMessage): string | null {
const cookies = parseCookie(req.headers.cookie ?? '');
if (cookies.session) return cookies.session; // (1) httpOnly cookie

const { query } = parseUrl(req.url ?? '', true);
if (typeof query.ticket === 'string') return query.ticket; // (2) query ticket

// (3) Sec-WebSocket-Protocol: client sends ["jwt", "<token>"]
const proto = req.headers['sec-websocket-protocol'];
if (proto) {
const parts = proto.split(',').map((p) => p.trim());
const i = parts.indexOf('jwt');
if (i !== -1 && parts[i + 1]) return parts[i + 1];
}
return null;
}

function verify(token: string): Claims {
// verify() throws on bad signature, expired exp, or wrong audience —
// we treat every throw as a 401 below. clockTolerance absorbs skew.
return jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'], // pin the alg; never trust the header alg
clockTolerance: CLOCK_SKEW_SEC,
}) as Claims;
}

const server = createServer();

server.on('upgrade', (req, socket, head) => {
const token = extractToken(req);
let claims: Claims;
try {
if (!token) throw new Error('missing token');
claims = verify(token);
} catch {
// Reject BEFORE 101: write a real HTTP 401 and destroy the TCP socket.
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}

// If the JWT arrived via subprotocol, we MUST echo a chosen protocol back.
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as WebSocket & { sub: string; exp: number }).sub = claims.sub;
(ws as WebSocket & { sub: string; exp: number }).exp = claims.exp;
wss.emit('connection', ws, req, claims);
});
});

// Enforce expiry MID-connection: the upgrade only proved the token was valid
// at handshake time. Schedule a forced close at exp so a long-lived socket
// cannot outlive its credential.
wss.on('connection', (ws: WebSocket & { exp: number }) => {
const msUntilExpiry = ws.exp * 1000 - Date.now();
const timer = setTimeout(() => {
ws.close(4401, 'token expired'); // app close code in the 4000–4999 range
}, Math.max(0, msUntilExpiry));
ws.on('close', () => clearTimeout(timer)); // always clear to avoid timer leaks
});

server.listen(8080);

The verifyClient shortcut works for cookie-only setups (new WebSocketServer({ server, verifyClient: ({ req }, cb) => { /* cb(false, 401) */ } })), but it cannot select a subprotocol and its callback signature makes returning custom status codes awkward — prefer the manual handler above once you support tickets or Sec-WebSocket-Protocol. Either way, this socket-level auth complements the transport-level hardening in Security & TLS Configuration; a stolen ticket on a plaintext ws:// link defeats the whole scheme, so terminate TLS first.

Operational checklist #

Where the token travels matters as much as whether it verifies, because each transport has a different exposure.

Where to put the token A comparison of the five places a WebSocket credential can travel, showing which are visible in logs, which the browser API supports, and the verdict for each. Where to put the token Visible in logs Works in browser Verdict Query string yes — proxies, history yes short-lived ticket only Sec-WebSocket-Protocol yes — header logs yes workable, abuses the field Cookie no yes, same-site good with origin check Authorization header no not from the browser API native clients only First message after open no yes open window before auth The browser WebSocket constructor cannot set headers, which is the constraint that shapes every option here
No option is free. Cookies plus an origin check, or a single-use ticket, are the two defensible defaults.
Token lifetime against connection lifetime A connection verified once at the upgrade continues streaming long after its token has expired and after the session has been revoked elsewhere, because nothing re-evaluates authorisation. Token lifetime against connection lifetime authorised by a token that is no longer valid upgrade verifies token (0min) token expires — nothing re-checks (15min) user logged out elsewhere (22min) connection still open and streaming (60min) The upgrade check is necessary and not sufficient — expiry and revocation both need an in-band mechanism
Verifying once is where most implementations stop, and it is where the interesting failure begins.

FAQ #

Why can’t I just send an Authorization header from the browser? #

The browser WebSocket constructor only accepts a URL and an optional protocols argument — there is no API surface for custom request headers. That restriction is specified, not a bug, so the cookie, query-ticket, and Sec-WebSocket-Protocol patterns exist precisely to work around it. Non-browser clients (Node ws, mobile SDKs) can set headers, so a Bearer header is fine there.

Is putting the JWT in the query string safe? #

Only if it is a dedicated, short-lived ticket. URLs land in nginx/ALB access logs, APM traces, and browser history, so never put a long-lived session JWT there. Issue a 30–60 second single-use ticket scoped with its own aud, exchange it on the upgrade, and the leak window is too small to matter.

What happens when the JWT expires mid-connection? #

The upgrade check only proves validity at handshake time. Read exp from the verified claims, schedule a setTimeout to ws.close(4401, 'token expired') at that moment, and clear the timer on close. The client then reconnects with a freshly issued token — this is also why you keep ticket TTLs short and let reconnection logic refresh credentials.

Does verifyClient or a manual upgrade handler scale better behind a load balancer? #

Both are equivalent under sticky-session load balancing since validation is stateless JWT verification — no shared store needed. Prefer the manual server.on('upgrade') handler because it returns a real HTTP 401 the balancer and client can read, and it lets you parse all three token transports in one place.

Can I use this with Socket.IO instead of raw ws? #

Socket.IO exposes an io.use((socket, next) => …) middleware and an allowRequest hook that run during its handshake, so the same three transports apply, but the rejection is a Socket.IO connect_error rather than a raw HTTP 401. The token-extraction and jsonwebtoken.verify logic transfers unchanged; only the rejection mechanics differ.

Is a token in the query string acceptable? #

Only as a single-use ticket with a lifetime measured in seconds. Query strings are logged by proxies, load balancers and browsers, so a long-lived access token there is effectively a credential written to several systems you do not control. Issue a short ticket from an authenticated HTTP request, spend it on the upgrade, and invalidate it immediately — the exposure window is then too small to matter.

What happens when the token expires mid-connection? #

Nothing, unless you build it — which is the gap this page’s check leaves open. The connection stays authorised by a credential that is no longer valid, sometimes for hours. Add an in-band refresh so the client can hand over a new token on the existing socket, plus a sweep that closes connections whose token has lapsed past a grace period: rotating WebSocket tokens without dropping connections.

Back to WebSocket Authentication & Authorization.