By AndyPublished

WebSocket JWT Authentication: Passing and Verifying a Token on the Handshake

The problem with WebSocket JWT authentication is one browser limitation: the browser WebSocket constructor takes only a URL and an optional subprotocol, so a page cannot set an Authorization: Bearer header on the handshake the way fetch can. That rules out the obvious approach and leaves four that work from a browser: send the token in a cookie the browser attaches automatically, send it in the first message after the socket opens, smuggle it through the Sec-WebSocket-Protocol subprotocol header, or put it in the query string(which gets logged). The most robust pattern is a short-lived ticket: the page fetches a one-time token over authenticated HTTP, then opens the socket with that. Whichever you pick, verify the token during the HTTP upgrade and check the Origin header. This guide has runnable code for the ws library and Socket.IO, plus Spring and FastAPI pointers.

Why You Can't Just Set a WebSocket JWT Header

A WebSocket connection begins as an ordinary HTTP GET carrying an Upgrade: websocketheader (RFC 6455). On the server you have full access to that request, including headers and cookies. The asymmetry is on the client: the browser's WebSocket(url, protocols) API deliberately exposes no way to add request headers. Non-browser clients do not have this restriction, Node's ws, Python's websockets and most native libraries let you pass an Authorization header, which is why so much advice that "just sends a header" only works outside the browser. The table below is the honest menu for browser clients.

ApproachWorks in a browser?Main risk
Cookie on the handshakeYes (sent automatically)Cross-site hijacking (CSWSH) unless Origin is checked
Token in the first messageYesConnection open but unauthenticated for a moment
Sec-WebSocket-Protocol subprotocolYesToken in a header the server must echo; awkward, size limits
Query string ?token=YesLogged in access logs and proxies
Authorization headerNo (browser API forbids it)Fine for server-to-server clients only
Short-lived ticketYesNeeds an extra HTTP endpoint to mint the ticket

Four Ways to Pass a WebSocket JWT Token

If your access token already lives in an HttpOnly cookie, the browser sends it on the upgrade request automatically and you verify it server-side. This is clean and keeps the token out of JavaScript, but it inherits the cross-site risk of any cookie auth: a page on another origin can open a WebSocket to your server and the cookie rides along. This is Cross-Site WebSocket Hijacking (CSWSH). WebSocket handshakes are not covered by the same-origin policy or CORS, so you must check the Origin header yourself and reject unknown origins. A SameSite cookie helps but is not sufficient across all browsers, so keep the Origin check. Whether your token is in a cookie at all is decided earlier, at storage time; see where to store a JWT.

2. Token in the first message

Open the socket, then have the client send { "type": "auth", "token": "..." }as its first frame. The server starts a short timer on connect and closes the socket if a valid token does not arrive in time. This works everywhere and keeps the token out of the URL, at the cost of a brief window where the connection is open but unauthenticated, during which you must send nothing and accept nothing else.

3. The Sec-WebSocket-Protocol subprotocol trick

The one header the browser does let you influence is the subprotocol list, via the second argument to new WebSocket(url, protocols). A common trick passes two "protocols": a real marker and the token, for example new WebSocket(url, ['access_token', jwt]). The server reads the token from Sec-WebSocket-Protocol and, importantly, must echo one accepted subprotocol back or compliant browsers abort the connection. Caveats: the token is now visible in a header (so still keep connections over TLS), header size limits apply, and it is a misuse of a field meant for protocol negotiation, so document it. It is popular because it is the only way to get a token into a request header from a browser without a cookie.

4. Query string (with a caveat)

new WebSocket('wss://host/ws?token=' + jwt) is the simplest option and works everywhere, but the full URL, token included, tends to be written to server access logs, proxy logs and any monitoring in the path. A bearer token in logs is a credential in logs. If you must use it, keep the token short-lived and prefer a single-use ticket (below) so a logged value expires almost immediately.

The Short-Lived Ticket Pattern

The pattern that sidesteps every drawback: before connecting, the page makes an authenticated HTTP call (where it can send a normal Authorization header) to an endpoint that returns a one-time, short-lived ticket, seconds, not minutes. It then opens the socket with ?ticket=.... The server validates and immediately burns the ticket. Because the ticket is single-use and expires almost at once, its appearance in a log is close to harmless, and your long-lived access token never touches the WebSocket URL. This is the approach large real-time services generally use.

Verifying a JWT on the Upgrade with Node and ws

Verify during the HTTP upgrade so an unauthenticated client never becomes a live WebSocket. Using noServer: true gives you the raw upgrade request to check the token and the Origin before calling handleUpgrade. This example runs against a live server and was tested end to end.

// npm install ws jose   (Node 18+, ES module)
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);

const server = createServer();
const wss = new WebSocketServer({ noServer: true });

function reject(socket, status, text) {
  socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`);
  socket.destroy();
}

server.on('upgrade', async (req, socket, head) => {
  socket.on('error', () => socket.destroy());

  // Defend against Cross-Site WebSocket Hijacking: the handshake is not covered by CORS
  if (!ALLOWED_ORIGINS.has(req.headers.origin)) return reject(socket, 403, 'Forbidden');

  // Token from a cookie (browser) or Authorization header (native clients)
  const cookie = /(?:^|;\s*)access_token=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
  const bearer = req.headers.authorization?.replace(/^Bearer /, '');
  const token = cookie ?? bearer;
  if (!token) return reject(socket, 401, 'Unauthorized');

  try {
    const { payload } = await jwtVerify(token, secret, {
      algorithms: ['HS256'],
      issuer: 'https://auth.example.com',
      audience: 'realtime-api',
    });
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req, payload));
  } catch {
    reject(socket, 401, 'Unauthorized');
  }
});

wss.on('connection', (ws, req, user) => {
  // Long-lived connection: close it when the token expires (see re-auth below)
  const timer = setTimeout(() => ws.close(4001, 'token expired'), user.exp * 1000 - Date.now());
  ws.on('close', () => clearTimeout(timer));
  ws.send(`hello ${user.sub}`);
});

server.listen(8080);
ℹ
Tested locally with ws 8 and jose 6 on Node 22: a request with no token is rejected 401, a valid token from a disallowed Originis rejected 403, and a valid same-origin token connects and is closed with code 4001 the moment the token expires.

Verify exactly as you would on the HTTP side: pin the algorithm with an allow-list, and check iss and aud. The verification guide covers each family; the checks do not change because the transport is a WebSocket.

Socket.IO: the auth Option in the Handshake

Socket.IO has a dedicated place for this. The client passes an auth object (or a callback returning one) in the handshake, and the server reads it from socket.handshake.auth inside a namespace middleware registered with io.use(). Calling next(err) refuses the connection. Tested with Socket.IO 4.8.

// Server
import { Server } from 'socket.io';
import { jwtVerify } from 'jose';

const io = new Server(3000, { cors: { origin: 'https://app.example.com' } });
const secret = new TextEncoder().encode(process.env.JWT_SECRET);

io.use(async (socket, next) => {
  try {
    const { payload } = await jwtVerify(socket.handshake.auth.token ?? '', secret, {
      algorithms: ['HS256'],
      audience: 'realtime-api',
    });
    socket.data.user = payload;
    next();
  } catch {
    next(new Error('unauthorised'));   // client sees a connect_error
  }
});

io.on('connection', (socket) => socket.emit('hello', socket.data.user.sub));

// Client
import { io } from 'socket.io-client';
const socket = io('https://app.example.com', { auth: (cb) => cb({ token: currentAccessToken }) });
socket.on('connect_error', (err) => console.log(err.message));

Using a callback for auth rather than a static object means the freshest token is read on every (re)connection attempt, which matters when tokens rotate. Socket.IO reconnects automatically, so returning an expired token here is the natural place for the client to refresh first.

Spring WebSocket JWT (STOMP ChannelInterceptor)

Spring's messaging stack authenticates STOMP over WebSocket at the CONNECTframe, not the HTTP handshake, which suits browsers because the token can travel in a STOMP header the JavaScript client controls. You register a ChannelInterceptor on the client inbound channel, read the token from the CONNECT frame's headers, and call accessor.setUser(...).

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(new ChannelInterceptor() {
      @Override
      public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
            MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
          String token = accessor.getFirstNativeHeader("Authorization"); // set by the JS client
          // validate the JWT, then:
          // accessor.setUser(authenticatedPrincipal);
        }
        return message;
      }
    });
  }
}

The Spring reference notes two things worth repeating: browser clients "can use only standard authentication headers ... or cookies and cannot provide custom headers" on the raw WebSocket, which is why the token goes in a STOMP frame header instead; and if you combine this with Spring Security message authorisation, your interceptor must run first, declared in its own WebSocketMessageBrokerConfigurer annotated @Order(Ordered.HIGHEST_PRECEDENCE + 99). Confirm exact class names against your Spring version. See JWT in Spring Bootfor the HTTP side.

FastAPI WebSocket JWT

FastAPI lets a WebSocket endpoint use the same dependency injection as HTTP routes, including Cookie and Query. Because a browser cannot set a header, the documented approach reads the token from a cookie or a query parameter and raises WebSocketException(code=status.WS_1008_POLICY_VIOLATION) to reject it.

from typing import Annotated
from fastapi import (
    Cookie, Depends, FastAPI, Query, WebSocket, WebSocketException, status,
)
import jwt   # PyJWT

app = FastAPI()

async def get_token(
    websocket: WebSocket,
    session: Annotated[str | None, Cookie()] = None,
    token: Annotated[str | None, Query()] = None,
):
    raw = session or token
    if raw is None:
        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
    try:
        return jwt.decode(raw, SECRET, algorithms=["HS256"], audience="realtime-api")
    except jwt.PyJWTError:
        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)

@app.websocket("/ws")
async def ws(websocket: WebSocket, claims: Annotated[dict, Depends(get_token)]):
    await websocket.accept()
    await websocket.send_text(f"hello {claims['sub']}")

The cookie path is preferable to the query path for the logging reason above; the query path pairs well with the ticket pattern. See JWT authentication in FastAPI for token issuance and the PyJWT verification options.

Re-Authenticating Long-Lived Connections

A WebSocket can stay open for hours; the token that opened it expires in minutes. Verification happens once at the handshake, so an expired token does not close an established socket on its own. Two approaches, often combined:

  • ·Close on expiry: at connect time, schedule a timer for exp and close the socket (application close code such as 4001) when it fires, forcing the client to reconnect with a fresh token, as in the ws example.
  • ·Re-auth in band: let the client send a new token over the open connection before the old one expires; the server re-verifies and resets the timer, avoiding a reconnect.

If you revoke tokens (see JWT logout and revocation), remember that live sockets outlive the revocation unless you also track connections per user and close them on logout.

Summary

Browser WebSockets cannot carry an Authorization header, so authenticate with a cookie (and always check Origin against CSWSH), a first message, the subprotocol trick, or best of all a short-lived single-use ticket. Verify the JWT during the HTTP upgrade with the same algorithm allow-list, iss and audchecks you use over HTTP, and reject before the socket goes live. For long-lived connections, close or re-authenticate when the token expires. The ws and Socket.IO examples above are tested; Spring and FastAPI expose the same idea at the STOMP CONNECT frame and through dependency injection respectively. If a handshake is refused, decode the token you sent in the jwtdecode.app decoder, which runs entirely in your browser, to confirm its aud and exp before blaming the socket.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder