HTTP のリクエストは、毎回認証情報を運びます。WebSocket が運ぶのは、ハンドシェイクのときの1回だけです。そのあとサーバーが持つのは「このソケットは alice のもの」という判定結果で、根拠そのものではありません。判定を支えたトークンが失効しても、取り消されても、権限が減っても、開いているソケットは何も変わりません。

この記事では、1つのトークンが接続の一生をどう通っていくかを追い、各段階でブラウザに何ができるかを確かめます。そのうえで、更新の3つの方法を比べます。コードはすべて、末尾に書いた環境で実行しています。

TL;DR

  • 101 Switching Protocols のあとは HTTP リクエストが発生しないので、トークンを再検証する場所がありません。サーバーが自分で期限を強制しない限り、接続は認証情報より長く生きます。
  • ブラウザのページは、WebSocket にヘッダーを付けられません。ハンドシェイクが失敗した理由も分かりません(HTTP 401 もネットワーク障害も、close コード 1006 として届きます)。トークンの置き場所とエラーの伝え方は、自分で設計することになります。
  • 更新の方法は3つです。閉じて再接続する、同じ接続の上で新しいトークンを送る(in-band)、HTTP で接続を指名して送る(out-of-band)。どれを選んでも、次の4つの規則は共通です。サーバーが期限を強制する、主体(subject)を変えさせない、スコープを広げさせない、期限を縮めさせない。失敗はすべて、普通の再接続に落とします。
  • タイマーで扱えるのは「期限切れ」だけです。失効(revocation)や権限変更は、有効期間を短くするか、自分のシステムから切断を押し込む必要があります。

1つのトークンの旅

第1駅:コンストラクターにはヘッダーを置く場所がない

WHATWG の仕様で、WebSocket は new WebSocket(url, protocols) です。オプション引数はないので、ページから Authorization ヘッダーは付けられません(Node の ws のような非ブラウザのクライアントなら付けられます)。トークンは別の場所に乗せる必要があります。

トークンの置き場所ブラウザで使えるかコスト
Authorization ヘッダー使えない非ブラウザのクライアントだけ。
Cookie使える(自動で付く)暗黙の権限(ambient authority)です。SameSite がクロスサイトのリクエストから Cookie を外してくれない限り、別サイトのページが、そのユーザーの Cookie でソケットを開こうとできます。それだけに頼ってはいけません。RFC 6455 §10.2 は、特定のサイトからの入力だけを受け付けるサーバーは Origin を検証し、それ以外には 403 を返すべきだとしています。
クエリ文字列使えるURI は多くの場所に記録され、表示されます(RFC 9110 §17.9)。URL に入れたトークンはログに残ります。
Sec-WebSocket-Protocol使える秘密を運ぶために設計された機能ではなく、慣習です。サーバーは、提示された値のどれか1つを選んで返す必要があります。返さないと、ブラウザは接続を失敗させます(後述)。値は HTTP の token でなければならないので、base64url を使います。
open 後の最初のメッセージ使えるまだ誰か分からない接続を、サーバーが受け入れることになります。短い期限内に有効な auth が来なければ閉じます。

Sec-WebSocket-Protocol の行には、鋭い角があります。WHATWG のアルゴリズムでは、ページがサブプロトコルを提示したのにレスポンスがどれも名指ししなければ、接続は失敗します。RFC 6455 §4.1 単体では、クライアントが提示していない値をサーバーが返したときだけ失敗させればよいので、ブラウザのほうが厳しい挙動です。末尾の確認スクリプトで、Chrome でもそうなることを確かめられます。

第2駅:ハンドシェイクは拒否できるが、ページには理由が聞こえない

RFC 6455 §10.5 は、クライアント認証の方法を規定していません。サーバーは、汎用の HTTP サーバーが使えるものなら何でも使えます。つまり、不正なトークンを素の 401 で拒否してもかまいません。

ところが、ページから何が見えるかは別問題です。WHATWG の仕様は、スクリプトが区別できてはいけない失敗のリストを挙げています。その中に、オープニングハンドシェイクを完了しなかったサーバーが含まれており、区別できると、スクリプトがユーザーのローカルネットワークを探れてしまうから、という理由です。これらはすべて error と、コード 1006 の close イベントとして届きます。ヘッドレス Chrome 154 で、ハンドシェイクに 401 を返した場合と、提示したサブプロトコルを無視した場合を試すと、まさにそうなりました。

プローブ(ヘッドレス Chrome 154)openedclose コードreason
サーバーが bearer を選び、open 後に 4401 で閉じるyes4401token expired
サーバーがサブプロトコルを返さないno1006(空)
サーバーがアップグレードに HTTP 401 を返すno1006(空)

ここから実務上の帰結が1つ出ます。拒否されたハンドシェイクと、ネットワーク障害は、ページから見て同じです。 クライアントは 1006 を見たら「トークンが悪いのかもしれない」と考え、トークンを1回更新して1回だけ再試行し、それでも駄目なら待ち時間を置くのが現実的です。また、ページに「トークンの期限が切れた」ことを伝えたいなら、いったん接続を受け入れてから、アプリケーション用のコードで閉じる必要があります。RFC 6455 §7.4.2 は 4000〜4999 を私的利用に予約しており、この記事の 4401 はそこから取っています。(1006 自体は、線路の上には流れません。§7.4.1 は「異常終了」を示すために予約しています。)

第3駅:101 のあと、サーバーが覚えているのは判定結果

サーバーが 101 を返したあとの接続は、フレームが流れるバイト列にすぎません。どのフレームにも認証情報は載りません。サーバーの手元に残るのはハンドシェイク時に保存したもので、たいてい socket.claims = { sub, scope, exp } のような形です。

第4駅:3種類のずれ

トークンとソケットのずれ方は3通りあり、扱いやすさはそれぞれ違います。

  1. 期限切れ。 事前に分かります。サーバーのタイマーで扱えます。
  2. 失効(revocation)。 事前には分かりません。誰かが知らせるか、自分で見に行かない限り、サーバーには分かりません。
  3. 権限の変更。 同じく事前には分かりません。ロールの変更、スコープの縮小、アカウントの停止などです。

更新プロトコルが主に解けるのは、1つ目だけです。残りの2つに対して更新ができるのは、遅れの上限を決めることまでです。ソケットは現在のトークンより長生きできないので、最悪でも「残りの有効期間」で済みます。それでも長すぎるなら、有効期間を短くするか、自分のシステムから切断を押し込みます(たとえば、ユーザーがログアウトしたら、そのユーザーのソケットをすべて閉じる)。

更新の3つの方法

選択を左右するのは、「切断のコストはどれくらいか」という1つの問いです。答えが「ほとんどない。再開は安い」なら、いちばん単純な仕組みで足ります。「大きい。再接続すると多くの購読をやり直すことになる」なら、接続を生かし続けたくなります。簡単な設計判断記録にまとめます。

A. 閉じて再接続B. in-band の auth メッセージC. out-of-band の HTTP リクエスト
仕組みサーバーが期限で閉じる(コード 4401)。クライアントが新しいトークンを取り、つなぎ直すサーバーが reauth を送る。クライアントが同じソケットで新しいトークンを返すクライアントが、生きている接続を指名した普通の認証つき HTTP リクエストを送る
ストリームの連続性途切れる。再開の仕組みが要る(カーソルとスナップショット)保たれる保たれる
トークンを解釈する場所すでにあるハンドシェイクのコードWebSocket のメッセージハンドラーすでにある HTTP の認証スタック
ルーティングどのノードでもよい構造上、正しいノードに届くソケットを持つノードに届く必要がある(スティッキーなルーティングか、pub/sub の中継)
アプリのメッセージとの順序該当なし同じ接続なので、他のメッセージと順序が保たれる別の経路なので、close と競合しうる
増えるプロトコルの面なしメッセージ1種類エンドポイント1つ
失敗したとき再接続A に落ちるA に落ちる

切断が高くつかないなら A にします。すでにメッセージのプロトコルがあり、ストリームを保ちたいなら B です。認証やレート制限が HTTP 層にあり、ソケットを持つノードにルーティングできるなら C です。どの場合も、A がフォールバックです。B か C のどの段階が失敗しても、サーバーは期限で閉じ、クライアントは再接続します。この性質があるから、更新は「新しい壊れ方」ではなく「最適化」になります。

4つの規則を持つ、動くサーバー

新しいトークンをどの経路で運ぶにせよ、サーバーには同じ4つの規則が要ります。小さな規則ですが、それぞれが実際にありがちな間違いを防ぎます。

  1. 期限はサーバーが強制する。 期限の少し前に新しいトークンを求め、来なければ期限で閉じます。クライアントが戻ってくることを当てにしません。
  2. 主体を変えさせない。 他人の有効なトークンは、このソケットの有効な更新ではありません。
  3. スコープを広げさせない。 狭めるときはすぐ適用します。権限を増やすには新しいハンドシェイクが要るので、更新が黙って権限昇格にはなりません。
  4. 期限を縮めさせない。 古いトークン(有効だが現在のものより古い)は無視します。

規則2〜4は1つの関数です。規則1は別の関数です。

  // 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);
  }

サーバーは、スケジュールをクライアントに任せず、exp - leadMs で自分から促します。クライアントの時計は間違っているかもしれませんが、期限を決めるのはサーバーの時計です。クライアントは、聞かれたときに答えるだけです。

    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()}` } });
    });

戦略 B は in-band の分岐、戦略 C は out-of-band の分岐です(サーバー側は、下のリストの /renew/ ハンドラーです)。戦略 A は、再接続する既定の close ハンドラーです。3つに共通するのは最後の行で、何が起きても、ソケットが閉じたら再接続します。

token.mjs:署名つきトークンの簡易版(説明用)
// 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:ハンドシェイク、3つの戦略、期限の強制
// 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:500 ms のトークンで3つの戦略を実行
// 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();

実行結果

デモは、トークンの有効期間を 500 ms にして、各戦略を 2.6 秒ずつ走らせます。効果が数秒で見えるようにするためです。1回の実行結果です。

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

数字はこの簡易な環境のもので、手元では変わります。見てほしいのは形です。再接続では、サーバーが 4401 で5回閉じ、クライアントは6本の接続を開きました。in-band と out-of-band は、1本の接続のままでした。再接続の実行では tick の受信数も少なくなっています。接続と接続の間にいた時間は、受信していない時間だからです。実際のシステムでは、その間にサーバーが作ったものを再生するのが再開の仕組みの仕事で、戦略 A にそれが要る理由です。

性質をテストで固定する

上の性質は、1つずつテストで固定しています。下の環境で9つのテストが通りました。

reauth.test.mjs:規則ごとに1つのテスト
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/);
});

ドキュメントとして読む価値があるのは、次の2つです。

  • a stale token cannot shorten the lifetime:サーバーは有効なトークンを受け付けますが、期限を前に戻しません。
  • revocation does not cut off an open connection before it expires:このテストは、あえて悪い挙動を確認しています。トークンを失効させても、期限が来るまで開いているソケットは何も変わりません。これは、この記事のどの更新戦略でも買うことになる失効の遅れです。あとで気づくのではなく、最初から要件として書いておきます。

失敗モード

失敗何が起きるかどうするか
reauth が来たときにトークンのエンドポイントが遅い、または落ちている更新が届かず、サーバーが期限で 4401 を返して閉じるクライアントが再接続する。leadMs は、トークンのエンドポイントのタイムアウトに往復時間を足した値より大きくする。
クライアントの時計がずれているクライアントが更新時刻を自分で決めると、遅れたり、短い間隔で繰り返したりする上のように、サーバーに促させる。
ソケットが開いている間にトークンを失効させたソケットは期限まで生き続ける有効期間を短くする。または、失効を行うシステムから、主体を指定してソケットを閉じる。
権限を狭めた古いスコープがソケットに残る次の更新で狭いスコープを適用する。待てないなら切断を押し込む。
別の主体の更新チェックがなければ、あるユーザーが別のユーザーのソケットを延長できるsub を比べ、4403 で閉じる(テスト:a token for another subject is refused)。
更新でスコープが広がる黙った権限昇格拒否し、新しいハンドシェイクを要求する(テスト:a renewal cannot widen the scope)。
古い更新の再送期限を縮めたり、リセットしたりしうるexp を延ばさないトークンは無視する。
クエリ文字列のトークンアクセスログやプロキシに残るサブプロトコル、最初のメッセージ、Origin 検証つきの Cookie のどれかにする。
Cookie 認証SameSite が止めない限り、別サイトのページが、ユーザーの Cookie でソケットを開けるハンドシェイクで Origin を検証する(RFC 6455 §10.2)。Origin はブラウザに対する防御で、認証の代わりにはなりません。
最初のメッセージで認証する方式未認証のソケットが資源を占める短い期限内に有効な auth が来なければ閉じる。
ハンドシェイクを 401 で拒否したページには 1006 が見え、ネットワークエラーと区別できないトークンを1回更新して1回だけ再試行してから、待ち時間を置く。

どの更新方式を使うか、使わないか

  • in-band か out-of-band の更新を使うのは、ソケットがトークンより長生きするのが普通で、再接続が高くつくときです。
  • 素の再接続を使うのは、ソケットを張り直すのが安い、またはすでに再開の経路があるときです。いちばん単純で、いつでもフォールバックになります。
  • 更新の仕組みを作らないのは、接続が数分で終わるときです。ハンドシェイクで認証し、ソケットは終わらせ、クライアントに新しいトークンで再接続させます。
  • 更新を失効の代わりにしないでください。「すべての端末からログアウト」を数秒で効かせたいなら、必要なのは長い更新プロトコルではなく、切断を押し込む経路です。

試してみる

以下はすべて Node.js 20 で実行しました。18 以降なら動くはずです。所要時間は10分ほどです。

mkdir reauth && cd reauth
npm init -y && npm i ws@8
# 上のリストから token.mjs, server.mjs, demo.mjs, reauth.test.mjs, browser_check.mjs を保存する
node demo.mjs                  # 3つの戦略。接続数を比べる
node --test reauth.test.mjs    # 9つのテスト
node browser_check.mjs         # Chrome か Chromium が必要

実際のブラウザが何を報告するかは、下のスクリプトで確かめられます。3つのエンドポイント(bearer を選んであとで 4401 で閉じるもの、サブプロトコルを選ばないもの、アップグレードに 401 を返すもの)を持つサーバーを起動し、ヘッドレス Chrome のページから開いて、ページに見えた code、reason、wasClean を表示します。google-chrome が PATH にない場合は、CHROME=/path/to/chrome を設定してください。

browser_check.mjs:ハンドシェイクを拒否されたとき、ページには何が見えるか
// 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" });

開いている間は、信頼され続ける

WebSocket は、ある一瞬に認証され、開いている間ずっと信頼されます。それが許容できるかは、3つで決まります。ソケットがトークンより長生きできる時間(あなたが決める期限で決まる)、失効をどれだけ速く効かせたいか(有効期間か、切断を押し込む経路で決まる)、再接続がどれだけ高いか(更新の経路を決める)。期限での再接続はいつも正しく、ときにそれで十分です。in-band や out-of-band の更新はその上の最適化で、あらゆる失敗が最後に再接続へ落ちる限り、安全なままでいられます。

環境と、確認していないこと

コードはすべて、Linux 6.12(x86-64)、Node.js 20.19、ws 8.22、ヘッドレスの Google Chrome 154 で実行しました。第2駅の表のブラウザの挙動は、その Chrome のバージョンで観察したものです。仕様についての記述は、リンク先のドキュメントから 2026-10-04 時点で引用しています。トークンの形式は、説明用に作った HMAC の簡易な構成です。デモは短い有効期間、単一プロセス、TLS なしで動かしており、どの数字もベンチマークではありません。戦略 C のマルチノードのルーティング、アイドル接続を閉じるプロキシ、Chrome 以外のブラウザは確認していません。