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
- Node 22 with
ws8 in a scratch directory:npm i ws@8. - Chrome, driven through the shared session helper. Browser figures here are one capture on one machine.
- The four files below. The server reads
AUTH_MODEso one program serves all three patterns. The token is a JWT-shaped string with a fake signature, because the point of the check is where the token travels, not how it is signed. See RFC 6455 section 4.1 for the handshake fields.
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
- 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 queryopen, subprotocol=(none) message authenticated close 1000 - Step 2.
Connect again with no token, to see the shape of the rejection.
node auth-client.mjs noneerror Unexpected server response: 401 close 1006The 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.
- Step 3.
Restart with
AUTH_MODE=subprotocoland offer the token as a subprotocol alongside the real one.node auth-client.mjs subprotocolopen, subprotocol=chat.v1 message authenticated close 1000The server selects
chat.v1, so the token is never echoed back in the response. - Step 4.
Run the same pattern from a browser, which is the client that cannot set headers any other way.
node auth-browser.mjslog: 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 1006The 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 twoproto=values: the library sendsdemo,chat.v1and Chrome sendsdemo, chat.v1. - Step 5.
Restart with
AUTH_MODE=first-messageand authenticate after the socket opens.node auth-client.mjs first-messageopen, subprotocol=(none) message authenticated close 1000 - Step 6.
Send a token the server does not accept and read the close code.
node auth-client.mjs first-message wrong.token.hereopen, subprotocol=(none) close 1008 bad token - Step 7.
Open a socket and send nothing, which is the case an unauthenticated client produces.
node auth-client.mjs noneopen, subprotocol=(none) close 1008 auth timeoutWithout 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
What to check next
- How to check websocket origin validation: the control that stops a browser page on the wrong domain, which a token does not.
- How to check if websocket is secure: a token over plain
ws://is readable by anyone on the path. - How to check JWT expiration: read the
expclaim before deciding how long a socket may stay open. - Websocket close code 1006: why a failed handshake never carries a reason.
- Decode a JWT: read the claims of the token the client is sending.
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.
Related on this site
intermediate10 minpublished updated Maks Verny