An HTTP request carries its credentials every time. A WebSocket carries them once, during the handshake, and after that the server keeps the verdict (“this socket is alice”) but not the evidence. If the token that justified the verdict expires, is revoked, or loses a permission, nothing about the open socket changes.

This article follows one token through the life of a connection, looks at what a browser lets you do at each step, and then compares three ways to renew it. All code was run in the environment listed at the end.

TL;DR

  • After 101 Switching Protocols no HTTP request is made, so nothing re-validates the token. The connection outlives the credential unless the server enforces a deadline itself.
  • A browser page cannot set headers on a WebSocket and cannot tell why a handshake failed (an HTTP 401 and a network outage both arrive as close code 1006). Where the token goes and how errors are reported are protocol decisions you must make.
  • Three renewal strategies: close and reconnect, send a new token in-band, send it out-of-band over HTTP naming the connection. Whichever you pick, keep the same four rules: the server enforces the deadline, the subject cannot change, the scope cannot widen, the lifetime cannot shrink. Every failure must fall back to a plain reconnect.
  • Expiry is the only kind of drift a timer can handle. Revocation and permission changes need either short lifetimes or a push from your own system.

The journey of one token

Station 1: the constructor has no place for a header

The WebSocket interface in the WHATWG specification is new WebSocket(url, protocols). There is no options argument, so a page cannot add an Authorization header (a non-browser client such as the Node ws library can). The token has to ride somewhere else:

Where the token goesWorks in a browserWhat it costs
Authorization headerNoOnly for non-browser clients.
CookieYes, automaticallyAmbient authority: unless SameSite keeps the cookie off cross-site requests, a page on another site can try to open the socket with the user’s cookies, and you should not rely on that alone. RFC 6455 §10.2 says servers that accept input only from certain sites should verify Origin and answer 403 for the others.
Query stringYesURIs are logged and displayed in many places (RFC 9110 §17.9). A token in the URL ends up in logs.
Sec-WebSocket-ProtocolYesA convention, not a feature designed for secrets. The server must select one of the offered values, otherwise the browser fails the connection (see below). Values must be HTTP tokens, so use base64url.
First message after openYesThe server accepts a connection it cannot attribute yet. Close it if no valid auth message arrives within a short deadline.

The Sec-WebSocket-Protocol row has a sharp edge. The WHATWG algorithm says that if the page offered subprotocols and the response does not name one, the connection fails. RFC 6455 §4.1 alone only requires failing when the server names a value the client did not offer; the browser is stricter. The check script at the end confirms this in Chrome.

Station 2: the handshake can say no, but the page cannot hear why

RFC 6455 §10.5 does not prescribe a client authentication mechanism: the server can use whatever a generic HTTP server can use. So rejecting a bad token with a plain 401 is legitimate.

What the page sees is another matter. The WHATWG spec lists a set of failures that a script must not be able to tell apart, among them “a server that did not complete the opening handshake”, because distinguishing them “would allow a script to probe the user’s local network”. All of them surface as error plus a close event with code 1006. In headless Chrome 154 I got exactly that for a handshake answered with 401, and for a server that ignored the offered subprotocol:

Probe (headless Chrome 154)openedclose codereason
Server selects bearer, then closes with 4401 after the openyes4401token expired
Server offers no subprotocol backno1006(empty)
Server answers the upgrade with HTTP 401no1006(empty)

This is the first practical consequence: a rejected handshake and a network failure look the same to the page. A client that sees 1006 should treat it as “maybe my token is bad”: refresh the token once, retry once, and only then back off. And if you want the page to learn that the token expired, accept the connection and close it with an application code. RFC 6455 §7.4.2 reserves 4000–4999 for private use, which is where 4401 in this article comes from. (1006 itself is never sent on the wire; §7.4.1 reserves it for “closed abnormally”.)

Station 3: after the 101, the server remembers a verdict

Once the server answers 101, the connection is a byte stream with frames on it. No frame carries a credential. The server’s only record is whatever it stored at handshake time, usually something like socket.claims = { sub, scope, exp }.

Station 4: three kinds of drift

The token and the socket can drift apart in three ways, and they are not equally easy:

  1. Expiry. Known in advance. A timer on the server can handle it.
  2. Revocation. Unknown in advance. The server learns it only if something tells it or it looks.
  3. Permission change. Same: a role changes, a scope is narrowed, an account is suspended.

A renewal protocol mostly solves the first. For the other two, the best a renewal can do is bound the delay: the socket cannot outlive its current token, so the worst case is the remaining lifetime. If that is too long for you, shorten lifetimes or push a close from your own system (for example, close every socket belonging to a user when that user logs out).

Three ways to renew

The decision is shaped by one question: what does a disconnect cost you? Suppose the answer is “very little, a resume is cheap.” Then you want the simplest mechanism. If the answer is “a lot, a reconnect means re-subscribing to many things”, you want the connection to survive. A short decision record:

A. Close and reconnectB. In-band auth messageC. Out-of-band HTTP request
MechanismServer closes at expiry (code 4401); client fetches a new token and connects againServer sends reauth; client replies on the same socket with a new tokenClient sends an ordinary authenticated HTTP request that names the live connection
Stream continuityBroken; needs resume logic (cursors and snapshots)PreservedPreserved
Where the token is parsedHandshake code you already haveThe WebSocket message handlerYour existing HTTP auth stack
RoutingAny nodeLands on the right node by constructionMust reach the node that holds the socket (sticky routing or a pub/sub hop)
Ordering with application messagesn/aSame connection, so ordered with everything elseSeparate channel; can race with a close
Extra protocol surfaceNoneOne message typeOne endpoint
Failure behaviorReconnectFalls back to AFalls back to A

Pick A unless a disconnect is expensive. Pick B when you already have a message protocol and want the simplest preserved stream. Pick C when your authentication and rate-limiting live in the HTTP layer and you can route to the node holding the socket. In all three, A is the fallback: if any step of B or C fails, the server will close at expiry and the client will reconnect. That property is what makes renewal an optimization instead of a new way to break.

A runnable server with the four rules

Whichever channel carries the new token, the server needs the same four rules. They are small but each one blocks a real mistake:

  1. The server enforces the deadline. It asks for a new token shortly before expiry and closes at expiry if none arrived. It does not trust the client to come back.
  2. Same subject. A valid token for somebody else is not a valid renewal of this socket.
  3. Never widen the scope. Narrowing is applied immediately. Gaining rights requires a new handshake, so a renewal cannot silently escalate.
  4. Never shorten the lifetime. A stale token (valid, but older than the current one) is ignored.

Rules 2–4 are one function. Rule 1 is another:

  // Renewal rules: same subject, never widen the scope, never shorten the lifetime.
  function accept(ws, c) {
    if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false;
    if (!c.scope.every((s) => ws.claims.scope.includes(s))) return false;   // more rights need a fresh handshake
    ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) };  // narrow now; extend, never shorten
    arm(ws);
    return true;
  }
  // The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry.
  function arm(ws) {
    clearTimeout(ws.warn); clearTimeout(ws.kill);
    const left = ws.claims.exp - Date.now();
    ws.warn = setTimeout(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "reauth" })), Math.max(0, left - leadMs));
    ws.kill = setTimeout(() => ws.close(CLOSE_EXPIRED, "token expired"), left);
  }

The server prompts at exp - leadMs instead of leaving the schedule to the client. The client’s clock may be wrong; the server’s clock is the one that decides expiry. The client only has to answer when asked:

    ws.on("message", async (raw) => {
      const m = JSON.parse(raw);
      if (m.type === "tick") stats.ticks++;
      if (m.type === "reauth" && strategy === "in-band") ws.send(JSON.stringify({ type: "auth", token: await getToken() }));
      if (m.type === "reauth" && strategy === "out-of-band")
        await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: "POST", headers: { authorization: `Bearer ${await getToken()}` } });
    });

Strategy B is the in-band branch; strategy C is the out-of-band branch (the server side is the /renew/ handler in the listing below); strategy A is the default close handler that reconnects. All three share the last line: whatever happens, if the socket closes, reconnect.

token.mjs: a toy signed token (illustration only)
// A toy signed token: base64url(JSON claims) + "." + HMAC. Illustration only; use a real JWT/PASETO library in production.
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
const SECRET = "demo-secret";
const b64 = (s) => Buffer.from(s).toString("base64url");
const sign = (p) => createHmac("sha256", SECRET).update(p).digest("base64url");

export const mint = (sub, ttlMs, scope = ["read"]) => {
  const p = b64(JSON.stringify({ sub, scope, exp: Date.now() + ttlMs, jti: randomUUID() }));
  return `${p}.${sign(p)}`;
};
export const verify = (token) => {
  const [p, s] = String(token).split(".");
  if (!p || !s || s.length !== sign(p).length || !timingSafeEqual(Buffer.from(s), Buffer.from(sign(p)))) return null;
  const claims = JSON.parse(Buffer.from(p, "base64url"));
  return claims.exp > Date.now() ? claims : null;
};
server.mjs: handshake, three strategies, deadline enforcement
// A WebSocket server that authenticates at the handshake and then keeps authorizing for the life of the connection.
import http from "node:http";
import { WebSocketServer } from "ws";
import { verify } from "./token.mjs";

export const CLOSE_EXPIRED = 4401, CLOSE_FORBIDDEN = 4403;     // 4000-4999: private use (RFC 6455 section 7.4.2)

export function startServer({ leadMs = 150, revoked = new Set() } = {}) {
  const conns = new Set();
  const wss = new WebSocketServer({ noServer: true, handleProtocols: () => "bearer" });
  const server = http.createServer(async (req, res) => {
    // Strategy C (out of band): an ordinary authenticated HTTP request that names a live connection.
    if (req.method === "POST" && req.url.startsWith("/renew/")) {
      const claims = verify((req.headers.authorization ?? "").replace(/^Bearer /, ""));
      const conn = [...conns].find((c) => c.id === req.url.slice(7));
      if (!claims || !conn) { res.writeHead(401).end(); return; }
      res.writeHead(accept(conn, claims) ? 204 : 403).end();
      return;
    }
    res.writeHead(404).end();
  });

  server.on("upgrade", (req, socket, head) => {
    const offered = String(req.headers["sec-websocket-protocol"] ?? "").split(/,\s*/);   // ["bearer", "<token>"]
    const claims = verify(offered[1]);
    if (!claims || revoked.has(claims.jti)) { socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); return; }
    wss.handleUpgrade(req, socket, head, (ws) => {
      ws.id = new URL(req.url, "http://x").searchParams.get("id"); ws.claims = claims; conns.add(ws);
      ws.on("close", () => { conns.delete(ws); clearTimeout(ws.warn); clearTimeout(ws.kill); });
      ws.on("message", (raw) => {                              // Strategy B (in band)
        let m; try { m = JSON.parse(raw); } catch { return ws.close(1008); }
        if (m.type === "auth") { const c = verify(m.token); if (!c || !accept(ws, c)) ws.close(CLOSE_FORBIDDEN, "renewal rejected"); }
      });
      arm(ws);
      wss.emit("ready", ws);
    });
  });

  // Renewal rules: same subject, never widen the scope, never shorten the lifetime.
  function accept(ws, c) {
    if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false;
    if (!c.scope.every((s) => ws.claims.scope.includes(s))) return false;   // more rights need a fresh handshake
    ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) };  // narrow now; extend, never shorten
    arm(ws);
    return true;
  }
  // The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry.
  function arm(ws) {
    clearTimeout(ws.warn); clearTimeout(ws.kill);
    const left = ws.claims.exp - Date.now();
    ws.warn = setTimeout(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "reauth" })), Math.max(0, left - leadMs));
    ws.kill = setTimeout(() => ws.close(CLOSE_EXPIRED, "token expired"), left);
  }
  return { server, wss, conns };
}
demo.mjs: runs all three strategies with 500 ms tokens
// Three ways to keep a connection alive past its token. Run: npm i ws && node demo.mjs
import WebSocket from "ws";
import { mint } from "./token.mjs";
import { startServer, CLOSE_EXPIRED } from "./server.mjs";

const TTL = 500, RUN = 2600;                                    // tiny lifetimes so that the demo takes seconds
const { server, wss } = startServer({ leadMs: 150 });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
wss.on("ready", (ws) => { let n = 0; ws.tick = setInterval(() => ws.readyState === 1 && ws.send(JSON.stringify({ type: "tick", n: ++n })), 100); ws.on("close", () => clearInterval(ws.tick)); });

const getToken = async () => mint("alice", TTL);                // stands in for your token endpoint

async function run(strategy) {
  const stats = { connections: 0, closes: [], ticks: 0 };
  let ws, stop = false, id = 0;
  const open = async () => {
    stats.connections++; id++;
    ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${id}`, ["bearer", await getToken()]);
    ws.on("message", async (raw) => {
      const m = JSON.parse(raw);
      if (m.type === "tick") stats.ticks++;
      if (m.type === "reauth" && strategy === "in-band") ws.send(JSON.stringify({ type: "auth", token: await getToken() }));
      if (m.type === "reauth" && strategy === "out-of-band")
        await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: "POST", headers: { authorization: `Bearer ${await getToken()}` } });
    });
    ws.on("close", (code) => { stats.closes.push(code); if (!stop) open(); });   // every strategy falls back to reconnect
    ws.on("error", () => {});
  };
  await open();
  await new Promise((r) => setTimeout(r, RUN));
  stop = true; ws.close();
  await new Promise((r) => setTimeout(r, 50));
  return stats;
}

for (const s of ["reconnect", "in-band", "out-of-band"]) {
  const { connections, closes, ticks } = await run(s);
  console.log(`${s.padEnd(12)} connections=${connections}  server closes with 4401=${closes.filter((c) => c === CLOSE_EXPIRED).length}  ticks received=${ticks}`);
}
server.close(); server.closeAllConnections();

What it does

The demo uses 500 ms token lifetimes and runs each strategy for 2.6 seconds, so the effect is visible in seconds. Output from one run:

reconnect    connections=6  server closes with 4401=5  ticks received=20
in-band      connections=1  server closes with 4401=0  ticks received=25
out-of-band  connections=1  server closes with 4401=0  ticks received=25

The numbers are from this toy setup and will differ on your machine; the shape is what matters. With reconnect, the server closed with 4401 five times and the client opened six connections. The in-band and out-of-band runs stayed on one connection. The reconnect run also received fewer tick messages: time spent between two connections is time spent not receiving. In a real system, whatever the server produced in that window is what a resume mechanism has to replay, which is why strategy A needs one.

Properties as tests

The properties above are each pinned by a test. Nine tests pass in the environment below.

reauth.test.mjs: one test per rule
import assert from "node:assert/strict";
import { test, before, after } from "node:test";
import WebSocket from "ws";
import { mint, verify } from "./token.mjs";
import { startServer, CLOSE_EXPIRED, CLOSE_FORBIDDEN } from "./server.mjs";

const revoked = new Set();
const { server, conns } = startServer({ leadMs: 100, revoked });
let port;
before(async () => { await new Promise((r) => server.listen(0, "127.0.0.1", r)); port = server.address().port; });
after(() => { server.close(); server.closeAllConnections(); });

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const connect = (token) => new Promise((resolve, reject) => {
  const ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${Math.random()}`, ["bearer", token]);
  ws.closed = new Promise((r) => ws.on("close", (code) => r(code)));
  ws.on("open", () => resolve(ws)); ws.on("error", reject);
});

test("without renewal the server closes with 4401 at expiry", async () => {
  const ws = await connect(mint("alice", 300));
  assert.equal(await ws.closed, CLOSE_EXPIRED);
});

test("a valid in-band renewal keeps the connection past the original expiry", async () => {
  const ws = await connect(mint("alice", 300));
  ws.send(JSON.stringify({ type: "auth", token: mint("alice", 2000) }));
  await sleep(500);
  assert.equal(ws.readyState, WebSocket.OPEN);
  ws.close();
});

test("a token for another subject is refused", async () => {
  const ws = await connect(mint("alice", 2000));
  ws.send(JSON.stringify({ type: "auth", token: mint("bob", 4000) }));
  assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});

test("a renewal cannot widen the scope", async () => {
  const ws = await connect(mint("alice", 2000, ["read"]));
  ws.send(JSON.stringify({ type: "auth", token: mint("alice", 4000, ["read", "write"]) }));
  assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});

test("a narrower scope is applied immediately", async () => {
  const ws = await connect(mint("alice", 2000, ["read", "write"]));
  ws.send(JSON.stringify({ type: "auth", token: mint("alice", 100, ["read"]) }));   // narrower and older
  await sleep(100);
  const server_side = [...conns].find((c) => c.claims.sub === "alice" && c.claims.scope.length === 1);
  assert.ok(server_side, "the server now holds the narrower scope");
  assert.equal(ws.readyState, WebSocket.OPEN);
  ws.close();
});

test("a stale token cannot shorten the lifetime", async () => {
  const ws = await connect(mint("alice", 2000));
  ws.send(JSON.stringify({ type: "auth", token: mint("alice", 100) }));   // valid, but older than the current expiry
  await sleep(400);
  assert.equal(ws.readyState, WebSocket.OPEN);
  ws.close();
});

test("revocation does not cut off an open connection before it expires", async () => {
  const old = mint("alice", 600);
  const ws = await connect(old);
  revoked.add(verify(old).jti);                                            // revoke the token the connection was admitted with
  await sleep(200);
  assert.equal(ws.readyState, WebSocket.OPEN);                             // still open: nothing re-checks revocation by itself
  assert.equal(await ws.closed, CLOSE_EXPIRED);                            // it ends at expiry, the worst-case revocation delay
});

test("a revoked token is refused as a renewal", async () => {
  const ws = await connect(mint("alice", 2000));
  const renewal = mint("alice", 4000); revoked.add(verify(renewal).jti);
  ws.send(JSON.stringify({ type: "auth", token: renewal }));
  assert.equal(await ws.closed, CLOSE_FORBIDDEN);
});

test("a revoked token cannot be admitted", async () => {
  const t = mint("alice", 2000); revoked.add(verify(t).jti);
  await assert.rejects(connect(t), /401/);
});

Two of them are worth reading as documentation:

  • a stale token cannot shorten the lifetime: the server accepts a valid token, but does not move the deadline backwards.
  • revocation does not cut off an open connection before it expires: the test asserts the bad behavior on purpose. Revoking the token does nothing to the open socket until its expiry. This is the revocation delay you buy with every renewal strategy in this article, so write it down as a requirement instead of discovering it later.

Failure modes

FailureWhat happensWhat to do
Token endpoint is slow or down when reauth arrivesNo renewal arrives; the server closes with 4401 at expiryThe client reconnects. Choose leadMs larger than the token endpoint’s timeout plus a round trip.
Client clock is wrongIf the client schedules renewal itself, it renews late or in a tight loopLet the server prompt, as above.
Token revoked while the socket is openThe socket stays up until its expiryShorten lifetimes, or close sockets by subject from the system that performs the revocation.
Permission narrowedOld scope remains on the socketApply the narrower scope at the next renewal; push a close if it cannot wait.
Renewal for another subjectWithout a check, one user could extend another’s socketCompare sub; close with 4403 (test: a token for another subject is refused).
Renewal widens the scopeSilent privilege escalationReject; require a new handshake (test: a renewal cannot widen the scope).
Old renewal replayedCould shorten or reset the deadlineIgnore tokens that do not extend exp.
Token in the query stringEnds up in access logs and proxiesPrefer a subprotocol, a first message, or a cookie with an Origin check.
Cookie authenticationA cross-site page can open the socket with the user’s cookies unless SameSite stops itVerify Origin in the handshake (RFC 6455 §10.2). Origin is a defence against browsers, not a substitute for authentication.
First-message authenticationAn unauthenticated socket occupies resourcesClose it if no valid auth arrives within a short deadline.
Handshake rejected with 401The page sees 1006, same as a network errorRefresh the token once and retry once before backing off.

Which renewal, if any

  • Use in-band or out-of-band renewal when sockets routinely outlive the token and a reconnect is expensive.
  • Use plain reconnect when sockets are cheap to re-establish or you already have a resume path. It is the simplest and it is always the fallback.
  • Do not build any renewal for connections that last for minutes. Authenticate at the handshake, let the socket end, and let the client reconnect with a fresh token.
  • Do not treat renewal as revocation. If “log out everywhere” must take effect in seconds, you need a push path, not a longer renewal protocol.

Try it

Everything below was run on Node.js 20; 18 or newer should work. It takes about ten minutes.

mkdir reauth && cd reauth
npm init -y && npm i ws@8
# save token.mjs, server.mjs, demo.mjs, reauth.test.mjs and browser_check.mjs from the listings above
node demo.mjs                  # three strategies, compare the connection counts
node --test reauth.test.mjs    # nine tests
node browser_check.mjs         # needs Chrome or Chromium

To see what a real browser reports, run the check below. It starts a server with three endpoints (one that selects bearer and later closes with 4401, one that selects no subprotocol, one that answers the upgrade with 401), opens them from a headless Chrome page, and prints the code, reason and wasClean the page saw. Set CHROME=/path/to/chrome if google-chrome is not on your PATH.

browser_check.mjs: what does a page see when a handshake is rejected?
// What can a page's script see when a WebSocket handshake or connection is rejected?
// Run: npm i ws && node browser_check.mjs   (needs Chrome/Chromium; set CHROME=/path if needed)
import http from "node:http";
import { spawn } from "node:child_process";
import { WebSocketServer } from "ws";

const page = `<script>
const results = {};
function probe(name, url, protocols) {
  return new Promise((resolve) => {
    const info = { opened: false, error: false, close: null };
    const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
    ws.onopen = () => { info.opened = true; info.protocol = ws.protocol; };
    ws.onerror = () => { info.error = true; };
    ws.onclose = (e) => { info.close = { code: e.code, reason: e.reason, wasClean: e.wasClean }; results[name] = info; resolve(); };
  });
}
(async () => {
  const base = "ws://" + location.host;
  await probe("subprotocol echoed, then server closes 4401", base + "/ok", ["bearer", "tok"]);
  await probe("subprotocol offered but not selected", base + "/noproto", ["bearer", "tok"]);
  await probe("HTTP 401 at the upgrade", base + "/unauth");
  await fetch("/report", { method: "POST", body: JSON.stringify(results, null, 1) });
})();
</script>`;

const http_ = http.createServer(async (req, res) => {
  if (req.url === "/") { res.writeHead(200, { "content-type": "text/html" }); return res.end(page); }
  if (req.url === "/report") {
    let b = ""; for await (const c of req) b += c;
    console.log(b); res.end("ok"); clearTimeout(guard); chrome.kill(); http_.close(); http_.closeAllConnections();
  }
});
const wss = new WebSocketServer({ noServer: true, handleProtocols: (set, req) => (req.url === "/noproto" ? false : "bearer") });
http_.on("upgrade", (req, socket, head) => {
  if (req.url === "/unauth") { socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; }
  wss.handleUpgrade(req, socket, head, (ws) => setTimeout(() => ws.close(4401, "token expired"), 100));
});
await new Promise((r) => http_.listen(0, "127.0.0.1", r));
const guard = setTimeout(() => { console.log("timeout"); chrome.kill(); process.exit(1); }, 20000);
const chrome = spawn(process.env.CHROME ?? "google-chrome", [
  "--headless=new", "--no-sandbox", "--disable-gpu", "--user-data-dir=./ws-check-profile",
  `http://127.0.0.1:${http_.address().port}/`,
], { stdio: "ignore" });

Trusted for as long as it stays open

A WebSocket is authenticated at one moment and trusted for as long as it stays open. Three things decide whether that is acceptable: how long a socket can outlive its token (set by your deadline), how fast you need revocation (set by your lifetimes or your push path), and how expensive a reconnect is (which picks the renewal channel). Reconnect-at-expiry is always correct and sometimes enough; in-band and out-of-band renewal are optimizations on top of it, and they stay safe only while every failure still ends in a reconnect.

Environment and what was not tested

All code was run on Linux 6.12 (x86-64) with Node.js 20.19, ws 8.22 and headless Google Chrome 154. The browser behaviors in the table under Station 2 were observed in that Chrome version; the specification statements are quoted from the linked documents as of 2026-10-04. The token format is a toy HMAC construction for illustration. The demo uses tiny lifetimes, a single process and no TLS; none of the numbers are benchmarks. Multi-node routing for strategy C, proxies that close idle connections, and other browsers were not tested.