Websocket authentication jwt

Run the handshake three ways against the same server. A token in the query string arrives in the access log as GET /?token=eyJ.... The same token offered as a Sec-WebSocket-Protocol value keeps it out of the URL, and a token sent in the first frame closes a bad session with code 1008.

Why check this

Run this when a socket endpoint is added, and again when the token format changes. The failure it prevents is a live credential written into the web server access log, the proxy log and the browser history, where log shipping then copies it to a search index that a much larger group of people can read.

A browser cannot set a request header on new WebSocket(). That single restriction is why socket authentication looks different from the rest of the API, and why the query string keeps being chosen. Two places take a token without putting it in a URL: the Sec-WebSocket-Protocol header, which the browser does set from the constructor's second argument, and the first frame after the socket opens.

Prerequisites

auth-server.mjs, on port 9315:

import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo';
const MODE = process.env.AUTH_MODE ?? 'query';

const server = createServer((req, res) => res.end('ok'));
const wss = new WebSocketServer({
  server,
  handleProtocols: () => 'chat.v1',
  verifyClient: ({ req }, done) => {
    console.log(`access log: GET ${req.url} proto=${req.headers['sec-websocket-protocol'] ?? '-'}`);
    if (MODE === 'query') {
      const token = new URL(req.url, 'http://h').searchParams.get('token');
      return done(token === TOKEN, 401, 'Unauthorized');
    }
    if (MODE === 'subprotocol') {
      const offered = (req.headers['sec-websocket-protocol'] ?? '').split(/,\s*/);
      return done(offered.includes(`auth.${TOKEN}`), 401, 'Unauthorized');
    }
    done(true);
  },
});
wss.on('connection', (ws) => {
  if (MODE !== 'first-message') return ws.send('authenticated');
  const timer = setTimeout(() => ws.close(1008, 'auth timeout'), 2000);
  ws.once('message', (m) => {
    clearTimeout(timer);
    if (String(m) === JSON.stringify({ type: 'auth', token: TOKEN })) ws.send('authenticated');
    else ws.close(1008, 'bad token');
  });
});
server.listen(9315, '127.0.0.1', () => console.log(`ws://127.0.0.1:9315 auth mode ${MODE}`));

auth-client.mjs, which takes the pattern as its first argument and an optional token as its second:

import WebSocket from 'ws';
const T = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo';
const how = process.argv[2];
const token = process.argv[3] ?? T;
const url = how === 'query' ? `ws://127.0.0.1:9315/?token=${token}` : 'ws://127.0.0.1:9315/';
const protocols = how === 'subprotocol' ? [`auth.${token}`, 'chat.v1'] : [];
const ws = new WebSocket(url, protocols);
ws.on('open', () => {
  console.log('open, subprotocol=' + (ws.protocol || '(none)'));
  if (how === 'first-message') ws.send(JSON.stringify({ type: 'auth', token }));
});
ws.on('message', (m) => { console.log('message ' + m); ws.close(1000); });
ws.on('error', (e) => console.log('error ' + e.message));
ws.on('close', (c, r) => console.log('close ' + c + (r.length ? ' ' + r : '')));

auth-page.mjs, a page on port 9316 that opens one socket with the token and one without:

import { createServer } from 'node:http';
const T = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo';
const page = `<!doctype html><meta charset=utf-8><title>ws auth</title><script>
const a = new WebSocket('ws://127.0.0.1:9315/', ['auth.${T}', 'chat.v1']);
a.onopen = () => console.log('with subprotocol: open, negotiated ' + a.protocol);
a.onmessage = (e) => console.log('with subprotocol: message ' + e.data);
const b = new WebSocket('ws://127.0.0.1:9315/');
b.onopen = () => console.log('without token: open');
b.onerror = () => console.log('without token: error event');
b.onclose = (e) => console.log('without token: close ' + e.code);
</script>`;
createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end(page); })
  .listen(9316, '127.0.0.1', () => console.log('http://127.0.0.1:9316'));

auth-browser.mjs:

import { open } from '../scripts/browser/session.mjs';
const s = await open();
try {
  await s.goto('http://127.0.0.1:9316/', { waitUntil: 'load' });
  await new Promise((r) => setTimeout(r, 2500));
  for (const m of s.console) console.log(m);
} finally { await s.close(); }

Steps

  1. Step 1.

    Start the server in query mode (AUTH_MODE=query node auth-server.mjs) and connect with the token in the URL.

    node auth-client.mjs query
    
    open, subprotocol=(none)
    message authenticated
    close 1000
  2. Step 2.

    Connect again with no token, to see the shape of the rejection.

    node auth-client.mjs none
    
    error Unexpected server response: 401
    close 1006

    The server console after both runs holds the reason this pattern is the wrong one:

    ws://127.0.0.1:9315 auth mode query
    access log: GET /?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo proto=-
    access log: GET / proto=-

    The credential is now in the request line. Every access log, proxy log and error report that records a URL has it, and TLS does not help, because the logging happens after the request is decrypted.

  3. Step 3.

    Restart with AUTH_MODE=subprotocol and offer the token as a subprotocol alongside the real one.

    node auth-client.mjs subprotocol
    
    open, subprotocol=chat.v1
    message authenticated
    close 1000

    The server selects chat.v1, so the token is never echoed back in the response.

  4. Step 4.

    Run the same pattern from a browser, which is the client that cannot set headers any other way.

    node auth-browser.mjs
    
    log: with subprotocol: open, negotiated chat.v1
    log: with subprotocol: message authenticated
    error: WebSocket connection to 'ws://127.0.0.1:9315/' failed: HTTP Authentication failed; no valid credentials available
    log: without token: error event
    log: without token: close 1006

    The server console for steps 3 and 4 together:

    ws://127.0.0.1:9315 auth mode subprotocol
    access log: GET / proto=auth.eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo,chat.v1
    access log: GET / proto=auth.eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.demo, chat.v1
    access log: GET / proto=-

    The URL is now / in every line. Compare the two proto= values: the library sends demo,chat.v1 and Chrome sends demo, chat.v1.

  5. Step 5.

    Restart with AUTH_MODE=first-message and authenticate after the socket opens.

    node auth-client.mjs first-message
    
    open, subprotocol=(none)
    message authenticated
    close 1000
  6. Step 6.

    Send a token the server does not accept and read the close code.

    node auth-client.mjs first-message wrong.token.here
    
    open, subprotocol=(none)
    close 1008 bad token
  7. Step 7.

    Open a socket and send nothing, which is the case an unauthenticated client produces.

    node auth-client.mjs none
    
    open, subprotocol=(none)
    close 1008 auth timeout

    Without the timer this socket stays open and unauthenticated for as long as the client keeps it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The token in the server's request line | The credential is in the URL and in every log that records URLs | Move it to a subprotocol value or the first frame, and rotate the tokens that were logged. | | Unexpected server response: 401 | The token was checked at the handshake and rejected | Nothing. A rejection before the upgrade is the cheapest one. | | close 1006 with no reason | The handshake failed, so there is no close frame to carry a reason | Read the HTTP status from the server log. 1006 alone is not a diagnosis. | | close 1008 with a reason | The socket opened and the application closed it on policy | Confirm that nothing was delivered on that socket before the close. | | open with no close after a bad token | Nothing enforces authentication after the upgrade | Add the timer from step 7. An open unauthenticated socket is a queue waiting to be subscribed. | | negotiated chat.v1 | The server picked the real subprotocol and did not echo the token | Nothing. This is the shape to keep. |

Common mistakes

Sign: The token is in the query string and the team says TLS makes it safe.Cause: TLS protects the URL in transit and nothing after. The server decrypts the request and then writes the request line to its access log, as step 2 shows, and the same URL reaches proxy logs, crash reports and browser history.
Sign: Browser clients are rejected while the Node test client passes with the same token.Cause: Chrome sends Sec-WebSocket-Protocol as a comma and a space, ws sends a comma with no space. Step 4 shows both lines from one server. A server that splits on ',' alone reads the browser's second value as ' chat.v1' and the token match still works, but any code matching a fixed position or trimming nothing breaks on one client only.
Sign: An expired token keeps working for hours.Cause: Authentication at the upgrade runs once. The socket then lives as long as the connection does, and no later frame is checked. Re-check the expiry on a timer inside the connection handler, and close with 1008 when it passes.
Sign: A rejected handshake shows up in the browser as an authentication prompt message, not as a status code.Cause: Chrome reports a 401 on the upgrade as 'HTTP Authentication failed; no valid credentials available' and a 403 as 'Unexpected response code: 403'. The status is only visible to the browser, so a tester reading the console alone cannot tell which rule fired.

What to check next

FAQ

Can a browser send an Authorization header on a WebSocket handshake?

No. new WebSocket(url, protocols) takes a URL and a protocol list, and nothing else. A client outside the browser can set the header, so an endpoint that accepts only Authorization works in the test suite and fails in the product.

Is a JWT in the query string acceptable over wss?

The frame traffic is encrypted, and the URL still reaches the server's log. Step 2 shows the token in the request line of a server doing nothing unusual. Treat a token in a URL as disclosed.

Which close code means authentication failed?

1008, policy violation, for a check made after the socket opens. A check made during the handshake produces an HTTP 401 or 403 instead, and the client reports 1006 because no close frame was ever sent.

How do I test that an expired token is rejected on a live socket?

Set the expiry short, open the socket, and hold it past the expiry without sending anything. The socket should close with 1008. Step 7 is the same shape with the timeout standing in for the expiry.

Does the subprotocol pattern hide the token completely?

It keeps the token out of the URL, which is where most logging picks it up. A server that logs full request headers still records it, so check what the access log format includes before treating the header as private.

Verified

Verified by Maks Vernynode 22.23.2ws 8.21.3Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny