WebSocket Authentication & Authorization #

An unauthenticated upgrade is an open door. The moment your server replies 101 Switching Protocols to any client that sends the right Upgrade header, you have handed an anonymous TCP socket a full-duplex channel into your application. Worse than a leaky REST endpoint: the connection is long-lived, so a single bad accept can stream private data for hours. The trap most teams fall into is assuming new WebSocket(url) behaves like fetch. It does not. The browser WebSocket constructor lets you set no custom headers — there is no Authorization: Bearer …, no X-Api-Key. If your auth strategy depends on a header the browser refuses to send, your only options become forwarding nothing and authenticating after the socket opens, which is exactly the window an attacker exploits.

The fix is to authenticate the HTTP request that carries the upgrade, before you ever emit the 101. This page covers the three header-free transports browsers actually allow — session cookies, short-lived ticket tokens in the query string, and a JWT smuggled through Sec-WebSocket-Protocol — plus authorizing individual channel subscriptions after the socket is live, and the harder problem of a token that expires while the connection stays open for hours.

Prerequisites #

This builds on the upgrade handshake itself. You should already have a working ws server accepting connections — see the parent Backend WebSocket Connection Management area for lifecycle and routing fundamentals. Authentication is meaningless over plaintext, so terminate TLS first: review Security & TLS Configuration so credentials and tokens never cross the wire in clear. Because cookie auth on a WebSocket is forgeable from any origin, pair this with handling cross-origin WebSocket connections to validate the Origin header. Concretely, before reading further you need:

  • A reverse proxy that forwards Upgrade, Connection, Cookie, Origin, and Sec-WebSocket-Protocol headers untouched to the Node process.
  • A JWT signing key (or JWKS endpoint) and a shared notion of sub, exp, and the claims you authorize against.
  • An allow-list of origins your app is served from.

The upgrade-time auth flow #

The decision point is the upgrade request. The TCP socket exists, but no WebSocket frames flow until you write the 101. That gives you one HTTP request — headers, cookies, query string — to validate before committing. Reject with 401 and the browser never opens the socket; accept and you attach the verified identity to the connection for the rest of its life.

WebSocket upgrade authentication flow The client sends an HTTP upgrade with a token; verifyClient validates the JWT and either completes the handshake with 101 or rejects with 401 before any frames are sent. Browser new WebSocket(url) verifyClient decode + verify JWT 401 Reject no socket opened 101 Switching user attached GET Upgrade + token invalid valid No frames flow until 101 is written

Core implementation #

The ws package gives you two hooks for upgrade-time auth. The simple one is verifyClient, a callback that runs before the handshake completes. The robust one is handling the server’s upgrade event yourself with handleUpgrade, which lets you do async verification (JWKS fetches, DB lookups) and write a proper 401 body on rejection. The example below uses the manual upgrade path because verifyClient’s async signature is awkward and cannot send a custom rejection response.

import { createServer, IncomingMessage } from "http";
import { WebSocketServer, WebSocket } from "ws";
import { jwtVerify, createRemoteJWKSet } from "jose";
import { Socket } from "net";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const CLOCK_TOLERANCE_S = 30; // accept tokens skewed +/- 30s vs our clock
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
const REAUTH_INTERVAL_MS = 5 * 60_000; // re-check token validity every 5 minutes

// We do NOT pass `server` to the WSS — noServer mode means we own the upgrade.
const wss = new WebSocketServer({ noServer: true });
const httpServer = createServer();

// Identity we attach to each socket once authenticated.
interface AuthedSocket extends WebSocket {
userId: string;
scopes: Set<string>;
tokenExp: number; // epoch seconds; used for mid-connection expiry checks
}

// Browsers can't set Authorization, so the token arrives one of three ways.
function extractToken(req: IncomingMessage): string | null {
// 1) Sec-WebSocket-Protocol: client passes ["jwt", "<token>"]; we echo "jwt" back.
const proto = req.headers["sec-websocket-protocol"];
if (proto) {
const parts = proto.split(",").map((p) => p.trim());
if (parts[0] === "jwt" && parts[1]) return parts[1];
}
// 2) Short-lived ticket in the query string (?ticket=...). Logged-but-disposable.
const url = new URL(req.url ?? "/", "http://localhost");
const ticket = url.searchParams.get("ticket");
if (ticket) return ticket;
// 3) Cookie-based session — verified separately; omitted here for brevity.
return null;
}

httpServer.on("upgrade", async (req, socket: Socket, head) => {
// Reject cross-origin upgrades before spending CPU on crypto.
const origin = req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.has(origin)) {
return reject(socket, 403, "Forbidden origin");
}

const token = extractToken(req);
if (!token) return reject(socket, 401, "Missing token");

let payload;
try {
// jose verifies signature AND exp, with bounded clock skew.
({ payload } = await jwtVerify(token, JWKS, {
clockTolerance: CLOCK_TOLERANCE_S,
issuer: "https://auth.example.com",
audience: "realtime-api",
}));
} catch {
return reject(socket, 401, "Invalid or expired token");
}

// Only now do we complete the handshake. If we passed the JWT via
// Sec-WebSocket-Protocol we MUST echo a chosen subprotocol back, or
// some browsers abort the connection.
wss.handleUpgrade(req, socket, head, (ws) => {
const authed = ws as AuthedSocket;
authed.userId = String(payload.sub);
authed.scopes = new Set((payload.scopes as string[]) ?? []);
authed.tokenExp = payload.exp ?? 0;
wss.emit("connection", authed, req);
}, "jwt");
});

// Write a real HTTP error and destroy the socket before any 101 is sent.
function reject(socket: Socket, code: number, msg: string): void {
socket.write(`HTTP/1.1 ${code} ${msg}\r\nConnection: close\r\n\r\n`);
socket.destroy();
}

wss.on("connection", (ws: AuthedSocket) => {
// Per-subscription authorization: the socket is authenticated, but every
// channel join is a separate authorization decision.
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "subscribe") {
if (!canSubscribe(ws, msg.channel)) {
ws.send(JSON.stringify({ type: "error", reason: "forbidden", channel: msg.channel }));
return; // silently drop — do NOT join the topic
}
joinChannel(ws, msg.channel);
}
});

// Mid-connection expiry: a long-lived socket can outlive its token.
const reauth = setInterval(() => {
if (Date.now() / 1000 >= ws.tokenExp) {
// Tell the client to refresh, then close with a policy code.
ws.send(JSON.stringify({ type: "reauth_required" }));
ws.close(4001, "token expired"); // app-defined 4000-4999 close code
}
}, REAUTH_INTERVAL_MS);
ws.on("close", () => clearInterval(reauth));
});

// Authorize a channel against the token's scopes. e.g. "org:42:read".
function canSubscribe(ws: AuthedSocket, channel: string): boolean {
if (channel.startsWith("public:")) return true;
return ws.scopes.has(`${channel}:read`);
}

function joinChannel(ws: AuthedSocket, channel: string): void {
ws.send(JSON.stringify({ type: "subscribed", channel }));
// ...register ws in your channel registry / Redis pub-sub map.
}

httpServer.listen(8080);

The key discipline: authentication happens once, at the upgrade; authorization happens repeatedly, per subscription. Conflating them is how privilege-escalation bugs ship — an authenticated user is not an authorized subscriber to every channel.

Configuration reference #

Parameter Type Default Production value Notes
Access token TTL duration 1h 5–15 min Short TTL limits the blast radius of a leaked query-string ticket.
Ticket TTL (query-param) duration 30–60 s Single-use, minted right before connect; expires before it can leak from logs.
Clock skew tolerance seconds 0 30 s Allows for drift between the auth service and WS node clocks.
Allowed origins string[] [] explicit allow-list Never *. The Origin header is the only CSRF defense for cookie auth.
Re-auth interval duration none 1–5 min How often a live socket re-checks exp; smaller = faster revocation.
Reject close code 4000–4999 4001 App-defined range so clients distinguish auth-close from network drop.
Subprotocol name string none jwt Must be echoed back in handleUpgrade or some browsers abort.

Edge cases & gotchas #

  • Tokens in the URL leak everywhere. A ?ticket=… or ?token=… query string lands in reverse-proxy access logs, browser history, Referer headers, and APM traces. Only put single-use, short-TTL tickets in the URL — never a full-lifetime JWT. Mint the ticket from an authenticated REST call, redeem it once on connect, then invalidate it server-side.
  • Expiry mid-connection is silent by default. jwtVerify checks exp only at the upgrade. A socket opened at 14:59 with a token expiring at 15:00 stays open and trusted for hours unless you re-check. The setInterval re-auth loop closes that hole; without it, revocation and expiry never reach an established connection.
  • Origin is the whole CSRF story for cookies. Browsers attach cookies to a WebSocket upgrade automatically and cross-origin, with no preflight and no same-origin policy on the connect itself. A malicious page can open a socket to your server using the victim’s cookie. Validating Origin against an allow-list is mandatory — see handling cross-origin WebSocket connections. Origin can be spoofed by non-browser clients, but those can’t ride a victim’s cookie, so the check still defeats browser-driven CSRF.
  • Per-message integrity for cross-tenant servers. If one process multiplexes many tenants, trusting a client-supplied channel field is a vector. Authorize every inbound subscribe/publish against the socket’s attached scopes, not against anything in the message body. The body is attacker-controlled; the attached identity is not.

Verification #

Prove an unauthenticated upgrade is actually refused, not silently accepted:

# No token: must NOT receive 101. Expect 401 and a closed socket.
websocat -v "ws://localhost:8080/" 2>&1 | grep -E "401|403"

# Valid token via subprotocol: expect "101 Switching Protocols".
websocat -v --protocol "jwt,$TOKEN" "ws://localhost:8080/" 2>&1 | grep "101"

# Wrong origin with otherwise-valid token: expect 403.
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Origin: https://evil.example.com" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
"http://localhost:8080/?ticket=$TICKET"

Confirm no full JWT appears in your access logs (grep -E 'token=|jwt ey' /var/log/nginx/access.log should return nothing), and assert the live socket count drops when a token’s exp passes by watching your ws_connections_active metric after a forced expiry. In DevTools, the Network tab’s WS entry should show 101 with your echoed jwt subprotocol under Response Headers.

Guides in this area #

FAQ #

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

The browser WebSocket constructor exposes no API for custom request headers — only the URL and an optional subprotocol list. That’s a deliberate restriction in the HTML spec. So the only header-free channels for credentials are the cookie jar (sent automatically), the query string, and Sec-WebSocket-Protocol. Native and server-side clients (like Node’s ws) can set headers, but you can’t rely on that for a browser app.

Is putting a JWT in the query string safe? #

Not for a long-lived token. Query strings leak into access logs, Referer headers, browser history, and traces. The acceptable pattern is a single-use ticket: mint a 30–60 second, one-time token from an authenticated REST endpoint, pass it as ?ticket=…, redeem and invalidate it on connect. By the time it surfaces in a log it’s already dead.

How do I handle a token that expires while the socket is open? #

Verify exp at the upgrade, then run a periodic re-auth check on the live connection (the setInterval loop above). When exp passes, send a reauth_required message so the client can fetch a fresh token and reconnect, then close with an app-defined code like 4001. Without this, an established socket stays trusted long after its token died.

Does Sec-WebSocket-Protocol JWT auth work behind a reverse proxy? #

Yes, as long as the proxy forwards the Sec-WebSocket-Protocol request header and you echo a chosen subprotocol back in the 101 response. Most proxies pass it through, but some strip non-standard headers — verify with curl -i against the proxy. Remember the server must respond with one of the offered protocols (here, jwt) or browsers abort the handshake.

What changes for Socket.IO vs raw ws? #

Socket.IO has a io.use() middleware that runs during its handshake, giving you socket.handshake.auth and socket.handshake.headers — a cleaner hook than raw ws. The principles are identical: authenticate the handshake, attach identity, authorize each socket.on(event) separately. The transport differs (Socket.IO may fall back to HTTP long-polling), so validate auth on every transport, not just the WebSocket one.

Back to Backend WebSocket Connection Management