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:
- 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.
- Short-lived ticket in the query string —
wss://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, andReferer-adjacent telemetry. Sec-WebSocket-Protocolheader — the one header the constructor can influence, via theprotocolsargument: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.
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 #
- Reject with an HTTP
401written to the raw socket beforehandleUpgrade - Pin the JWT algorithm (
algorithms: ['HS256']or['RS256']) so an attacker can’t downgrade toalg: none - Mint query-string tickets as a separate short-TTL token (≤60 s, single-use, distinct
aud - Echo a chosen subprotocol back when the token arrives via
Sec-WebSocket-Protocol - Schedule a forced
ws.close(4401)at the token’sexp - Add a
clockToleranceforexp/nbf - Log the rejection reason (missing / expired / bad-sig) at the upgrade layer; these never reach your
connection
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.
Related #
- WebSocket Authentication & Authorization — the parent guide covering identity and access control on real-time sockets.
- Enforcing Origin and CSRF Checks on WebSockets — the required companion when you authenticate via httpOnly cookie.
- Security & TLS Configuration — terminate TLS correctly so tokens never ride a plaintext upgrade.
- Protocol Handshake Mechanics — what the upgrade
GETand the101 Switching Protocolsresponse actually look like on the wire. - Load Balancer Sticky Sessions — keeping a verified socket pinned to its backend.