12k
All articles

WebSocket認証の解説

WebSocket認証を解説。URLのトークン、Cookie、サブプロトコル、初回メッセージ認証に加え、トークン更新とメッセージ単位の認可まで整理します。

OpenReplay Team
OpenReplay Team
WebSocket認証の解説

ブラウザのWebSocket APIはカスタムHTTPヘッダーの設定をサポートしていません。WebSocketコンストラクタはURLとオプションのサブプロトコル配列のみを受け付けるため、認証は以下の3つのメカニズムのいずれかを通じて行う必要があります。HTTPアップグレードハンドシェイク時にクエリ文字列でトークンを渡す方法、ブラウザが自動的に送信するセッションクッキーを使用する方法、または接続確立後の最初のメッセージで認証情報を送信する方法です。これが、各リクエストにAuthorization: Bearerヘッダーを付加するだけで済むREST認証とは異なる、WebSocket認証の制約です。

JWTやセッションクッキーを使ったREST認証を実装したことがあれば、必要な要素はすでに把握しているでしょう。WebSocketで変わるのは、認証情報の配送メカニズムとライフタイムです。RESTリクエストは1回認証して終了します。一方、WebSocket接続は数分から数時間にわたって維持されるため、ハンドシェイク時に有効だったトークンが、ソケットが開いている間に期限切れ、失効、または権限の変更が生じる可能性があります。本記事では、ハンドシェイク方式とファーストメッセージ方式のそれぞれについて、ブラウザとNode.jsの動作するコードを交えて解説します。また、どちらを選ぶかの判断基準を示し、多くのガイドが省略している2つの重要なトピック、つまり長期接続におけるトークン更新とメッセージごとの認可についても取り上げます。

重要なポイント

  • ブラウザのWebSocketコンストラクタはURLとサブプロトコル配列のみを受け付けるため、Authorizationヘッダーを送信することはできません。認証はクエリ文字列、クッキー、Sec-WebSocket-Protocolヘッダー、または最初のメッセージのいずれかで行う必要があります。
  • WebSocket認証は一度限りのイベントではなく、継続的なゲートです。接続が長期間維持されるため、メッセージごとの認可とトークン更新は本番環境における必須要件であり、オプションの強化策ではありません。
  • ソケットがステートフルなサブスクリプションを保持している場合はインバンドトークン更新を使用し、接続がステートレスでコンテキストの再構築が低コストな場合はクローズ&再接続を使用してください。
  • JWTのexpクレームはUnixエポックからの秒数を表すNumericDateであるため、クライアント側での更新スケジューリングではDate.now()と比較する前にexp * 1000で変換する必要があります。
  • よくある無音障害のパターンとして、トークンの期限切れによってソケットが切断されているにもかかわらず、UIが「接続済み」状態を表示し続け、その後のすべてのメッセージが失われるというケースがあります。これはセッションリプレイでは確認できますが、サーバーログには記録されません。

WebSocket認証が異なる理由

WebSocket認証はブラウザレベルで制約を受けます。JavaScriptのWebSocket APIでは、オープニングハンドシェイクにカスタムヘッダーを付加する手段が提供されていません。すべてのWebSocket接続はUpgrade: websocketヘッダーを含むHTTPのGETリクエストとして開始されますが、そのリクエストはブラウザが完全に制御しており、コードから指定できるのはURLとオプションのサブプロトコルリストのみです。WebSocketプロトコル自体もこの問題を解決しないことを明示しており、RFC 6455 §10.5によれば、このプロトコルは「WebSocketハンドシェイク中にサーバーがクライアントを認証する特定の方法を規定していない」とされています。

ブラウザのヘッダー制約を回避するための実用的なメカニズムは4つあります。

  1. クエリパラメータトークン — 接続URLにトークンを含める。
  2. クッキー/セッション — ブラウザが既存のセッションクッキーをアップグレードリクエストに自動的に付加する。
  3. Sec-WebSocket-Protocolサブプロトコル — サブプロトコル配列にトークンを埋め込む。
  4. ファーストメッセージ認証 — 未認証の状態でソケットを開き、最初のメッセージで認証情報を送信する。

以降のセクションでは、それぞれの方法についてブラウザクライアントのコードと、wsライブラリ(現在の安定版:8.x)を使用したNode.jsサーバーのコードを交えて解説します。

クエリパラメータトークン

接続URLにトークンを含め、接続リソースを割り当てる前のHTTPアップグレード時に検証します。これは最もシンプルで広く使われている方法であり、ソケットが確立される前にサーバーが401を返せるという高速拒否の利点があります。

// ブラウザクライアント
const token = localStorage.getItem('authToken');
const socket = new WebSocket(`wss://api.example.com/ws?token=${encodeURIComponent(token)}`);
// Node.jsサーバー — ws@8.21.0
import { WebSocketServer } from 'ws';
import { verify } from 'jsonwebtoken';

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

server.on('upgrade', (req, socket, head) => {
  const { searchParams } = new URL(req.url, 'wss://api.example.com');
  const token = searchParams.get('token');
  try {
    const user = verify(token, process.env.JWT_SECRET);
    wss.handleUpgrade(req, socket, head, (ws) => {
      ws.user = user;
      wss.emit('connection', ws, req);
    });
  } catch {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
  }
});

具体的なリスク: WebSocket URLに含まれるトークンは、nginxApacheのアクセスログにデフォルトで記録されます。どちらのデフォルトログフォーマット(combined / %r)もクエリ文字列を含む完全なリクエスト行を記録します。また、ブラウザの履歴に残る可能性があり、接続後にページ遷移が発生した場合はRefererヘッダーから漏洩する恐れもあります。短命なトークン(5〜15分は業界慣行であり、OWASPが文書化した数値ではありません)を使用することでリスクを軽減できますが、完全には排除できません。

クッキー/セッション

クッキーベースの認証は、HTTPで確立済みのセッションを再利用します。ブラウザはアップグレードリクエストに同一ドメインのクッキーを自動的に付加するため、クライアント側での認証コードは一切不要です。WebSocketエンドポイントがユーザーをログインさせたアプリと同じドメインを共有している場合、これが最も摩擦の少ない方法です。

// ブラウザクライアント — トークン処理は不要。クッキーが自動的に送信される
const socket = new WebSocket('wss://app.example.com/ws');
// Node.jsサーバー — ws@8.21.0
import { parse } from 'cookie';

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;
  if (origin !== 'https://app.example.com') {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    return socket.destroy();
  }
  const cookies = parse(req.headers.cookie || '');
  const session = sessionStore.get(cookies.sid);
  if (!session) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    ws.user = session.user;
    wss.emit('connection', ws, req);
  });
});

具体的なリスク: クッキーベースのWebSocket認証では、サーバー側でのOriginヘッダー検証と、SameSite=StrictまたはSameSite=Laxクッキー属性の両方が必要です。MDNのSameSiteドキュメントに記載されているように、SameSiteはクロスサイトリクエストでクッキーを送信するかどうかを制御します。SameSite=None(真にクロスサイトなクッキーに必要)は、SameSiteが導入された目的であるCSRFの攻撃面を再び露出させます。また、クッキーは異なるドメイン間では機能しないため、これが次の2つの方法が選ばれる主な理由です。

Sec-WebSocket-Protocolサブプロトコル

サブプロトコル配列はブラウザが公開している唯一のハンドシェイク要素であるため、サブプロトコル値としてトークンを渡すことができます。Sec-WebSocket-Protocolヘッダーはアプリケーションレベルのサブプロトコルをネゴシエートするために設計されており、認証トークンを運ぶためのものではありません。認証目的での使用はすべての主要ブラウザで動作する回避策ですが、最終手段として扱うべきです。

サブプロトコル値はセパレータ文字を含まない有効なtokenでなければならないため、トークンはパディングなしのbase64urlでエンコードする必要があります。標準base64の/=はここでは有効ではありません。base64urlアルファベットはRFC 4648 §5で定義されています。

// ブラウザクライアント — パディングなしのbase64urlサブプロトコル値としてトークンを渡す
const token = localStorage.getItem('authToken'); // すでにbase64url、パディングなし
const socket = new WebSocket('wss://api.example.com/ws', ['auth.bearer', token]);
// Node.jsサーバー — ws@8.21.0
const wss = new WebSocketServer({
  noServer: true,
  handleProtocols: (protocols) => {
    // protocolsはSet。2番目のエントリがトークン
    const [, token] = [...protocols];
    return validate(token) ? 'auth.bearer' : false;
  },
});

具体的なリスク: クエリパラメータ方式と同様に、サブプロトコル値はハンドシェイクヘッダーを記録するログに残る可能性があります。また、エンコード要件(パディングなしのbase64url)を誤りやすい点にも注意が必要です。この方法は、クッキーがクロスドメインでブロックされており、かつクエリパラメータの露出が許容できない場合に限定して使用してください。

ファーストメッセージ認証

接続は未認証の状態で開始され、クライアントが最初のメッセージとして認証情報を送信します。サーバーは他の処理を行う前に検証を実施します。この方法ではトークンがURLやログに一切残りませんが、代わりに自前でキューイングとタイムアウトのプロトコルを実装する必要があります。

// ブラウザクライアント — AUTH成功までアプリのメッセージをキューに入れる
const socket = new WebSocket('wss://api.example.com/ws');
const queue = [];
let authed = false;

socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'AUTH', token: localStorage.getItem('authToken') }));
};

socket.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'AUTH_OK') {
    authed = true;
    queue.forEach((m) => socket.send(m));
    queue.length = 0;
  }
};

function send(data) {
  const m = JSON.stringify(data);
  authed ? socket.send(m) : queue.push(m);
}
// Node.jsサーバー — ws@8.21.0
wss.on('connection', (ws) => {
  const timer = setTimeout(() => ws.close(4001, 'auth timeout'), 7000);
  ws.once('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type !== 'AUTH') return ws.close(4002, 'expected AUTH');
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      clearTimeout(timer);
      ws.send(JSON.stringify({ type: 'AUTH_OK' }));
    } catch {
      ws.close(4003, 'invalid token');
    }
  });
});

具体的なリスク: 未認証のWebSocket接続を受け付けるサーバーは、厳格な認証タイムアウト(一般的に5〜10秒、仕様上の根拠はない慣行)を設け、その時間内に有効な認証情報を送信しない接続を切断する必要があります。これを怠ると、攻撃者が認証されていないソケットを大量に開いて接続上限を枯渇させる可能性があります。適切な値はp95のラウンドトリップタイムによって異なります。低レイテンシ接続では5秒、モバイルや高レイテンシクライアントでは10秒が安全です。

方式の比較と選択基準

方式は抽象的な「最も安全なもの」ではなく、デプロイ環境の制約に基づいて選択してください。正しく実装すれば、4つの方式はすべて安全です。以下の表にトレードオフをまとめ、その後に一般的なケースを解決するための判断基準を示します。

方式トークンの露出CSRFリスククロスドメイン複雑さ使用場面
クエリパラメータトークンログ、履歴、Refererなしサーバーログを管理しており、最もシンプルな実装が必要な場合
クッキー/セッションなし(クッキーはHttpOnlyあり(SameSite + Originで軽減)不可同一ドメインで既存セッションがある場合
サブプロトコルヘッダーハンドシェイクログなしクッキーがブロックされ、クエリパラメータが許容できない場合
ファーストメッセージなしなしトークンをURLに含めたくなく、キューイングの複雑さを許容できる場合

判断基準:サーバーログを管理しており最もシンプルな実装が必要な場合はクエリパラメータトークンを使用する。同一ドメインでセッションが既に存在する場合はクッキーを使用する。トークンをURLに一切含めたくなく、かつキューイングの複雑さを許容できる場合はファーストメッセージ認証を使用する。クッキーがクロスドメインでブロックされており、かつクエリパラメータの露出が許容できない場合にのみサブプロトコルヘッダーを使用する。これは非標準の方法であり、最終手段として扱うべきです。これらをすべて自前で管理したくないチームは、トークンライフサイクルをサービスの一部として処理するマネージドリアルタイムプラットフォームを選択することが多いです。

長期接続におけるトークン更新

WebSocket接続はトークンの有効期限を超えて維持されるため、更新戦略を事前に決定しておく必要があります。戦略は2つあります。開いているソケット上でインバンドにトークンを更新する方法と、新しいトークンで切断・再接続する方法です。再接続時に失われるステートフルなサブスクリプションや保留中の操作がある場合はインバンドトークン更新を使用し、接続がステートレスでクライアントがデータ損失なしに低コストでコンテキストを再構築できる場合はクローズ&再接続を使用してください。 この選択はアーキテクチャ上の決定であり、スタイルの問題ではありません。

クライアントはJWT自身の有効期限からリフレッシュをスケジュールします。expクレームはRFC 7519 §4.1.4に従い、Unixエポックからの秒数を表すNumericDateであるため、Date.now()と比較する前に1000を掛ける必要があります。

// ブラウザ — 有効期限の60秒前にインバンド更新をスケジュール、失敗時は再接続にフォールバック
class WebSocketAuthManager {
  constructor(url) {
    this.url = url;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(`${this.url}?token=${getToken()}`);
    this.ws.onopen = () => this.scheduleRefresh();
    this.ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.type === 'TOKEN_REFRESH_OK') this.scheduleRefresh();
    };
  }

  scheduleRefresh() {
    const { exp } = parseJwt(getToken());        // expは秒単位
    const fireAt = exp * 1000 - Date.now() - 60_000; // 有効期限の60秒前
    clearTimeout(this.timer);
    this.timer = setTimeout(() => this.refresh(), Math.max(0, fireAt));
  }

  async refresh() {
    try {
      const fresh = await fetchNewToken();
      setToken(fresh);
      this.ws.send(JSON.stringify({ type: 'TOKEN_REFRESH', token: fresh }));
    } catch {
      this.ws.close(4004, 'refresh failed'); // クリーンな再接続にフォールバック
      this.connect();
    }
  }
}

サーバーは更新されたトークンを検証し、ソケットに紐づいたセッションを更新します。接続を切断しないことがインバンドパスの本質です。

// Node.jsサーバー — ws@8.21.0
ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  if (msg.type === 'TOKEN_REFRESH') {
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      ws.send(JSON.stringify({ type: 'TOKEN_REFRESH_OK' }));
    } catch {
      ws.close(4003, 'invalid refresh token');
    }
  }
});

認可は継続的なゲート

WebSocket認証は接続開始時の一度限りのイベントではありません。接続が長期間維持されるため、ハンドシェイク時に有効だったトークンが、ソケットが開いている間に期限切れ、失効、または関連する権限の変更が生じる可能性があります。そのため、メッセージごとの認可は本番環境における必須要件であり、オプションの強化策ではありません。権限は管理者操作、サブスクリプションの期限切れ、またはモデレーションによって接続中に変更される場合があります。1時間前に実行されたハンドシェイクでは、これらの変更を考慮することはできません。

解決策は、接続時だけでなく、受信するすべてのメッセージに対して認可チェックを行うことです。ハンドシェイクはアイデンティティの確立として扱い、各メッセージを新たな認可判断として処理します。

// Node.jsサーバー — メッセージごとの権限チェック
const PERMISSIONS = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] };

ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  const allowed = PERMISSIONS[ws.user.role] || [];
  if (!allowed.includes(msg.action)) {
    return ws.send(JSON.stringify({ type: 'FORBIDDEN', action: msg.action }));
  }
  handle(msg, ws);
});

これに失効チェックを組み合わせることで、ログアウトまたはBANされたユーザーが既に開いているソケットで操作を継続できないようにします。単一プロセスであれば、失効したトークンIDを格納する小さなインメモリのSetで十分です。ただし、複数のWebSocketノードを運用する場合は、失効情報が接続を保持しているどのノードからも参照できるよう、Redisなどの共有ストアが必要です。

セキュリティチェックリストと無音障害のデバッグ

WebSocket接続のセキュリティ確保は、具体的な短いチェックリストに集約されます。リリース前に以下を確認してください。

  • wss://のみを使用する。 RFC 6455 §10.6は、機密性と完全性のためにTLS上でWebSocketを実行することを推奨しています。平文のws://では、トークンとメッセージが通信経路上で露出します。
  • トークンを短命に保つ。 OWASPのセッション管理チートシートは、高価値アプリケーションでは2〜5分、低リスクのアプリケーションでは15〜30分のアイドルタイムアウトを推奨しています。ブラウザに保存されるトークンには短い方の値を選択してください。
  • ファーストメッセージ認証では認証タイムアウトを設ける(慣行として5〜10秒)。未認証のソケットは切断してください。
  • 接続をIPまたはユーザーごとにレート制限することで、未認証ソケットのチャーンを抑制します。具体的な数値(最大同時接続数など)はあくまで例示であり、標準ではありません。自社のトラフィックベースラインから設定してください。
  • 失効をサポートする。 接続時および各メッセージ受信時に失効リストを確認し、ログアウト済みセッションが開いているソケットで継続できないようにします。
  • クッキーベースの認証ではOriginを検証しSameSite=StrictまたはLaxを設定してください。

これらの項目が重要なのは、WebSocket認証が無音で失敗するからです。短命なトークンがセッション中に期限切れとなり、サーバーがソケットを閉じると、ブラウザはcloseイベントを発火します。アプリケーションがこのイベントを処理しない場合、UIは「接続済み」状態を表示し続け、その後のすべてのメッセージが無音で失われます。この障害はサーバーログには記録されませんが、セッションリプレイでは最後の成功メッセージの後にクライアント側の沈黙として確認できます。ファーストメッセージパターンでも同様です。低速な接続では、認証タイムアウトが先に発火した場合、認証完了前にキューに入れられたメッセージが失われる可能性があります。そのようなセッションのセッションリプレイでは、ソケットが開いているように見えながら、クライアントが送信したメッセージに対して一切レスポンスが返ってこない状況が確認できます。クライアント側をリプレイすることが、サーバーログにはクローズフレームしか記録されないバグのクラスを説明できる唯一の手段となることがあります。

まとめ

Authorizationヘッダーが使えないことを出発点として捉え、それだけで問題が解決したと考えないでください。上記の判断基準から適切な配送方式を選択し、接続が本番環境で長期間維持される前に、更新戦略とメッセージごとの認可を実装してください。ハンドシェイクは接続者のアイデンティティを証明するものに過ぎず、本当のWebSocketセキュリティはその後に存在します。まず既存のソケットを1つ監査することから始めてください。wss://で動作しているか、期限切れトークンをセッション中に拒否しているか、そして接続が切断された際にメッセージを無音で失うのではなく、ユーザーに切断を通知しているかを確認してください。

よくある質問

ブラウザのWebSocket接続でAuthorization Bearerヘッダーを送信できますか?

できません。ブラウザのWebSocketコンストラクタはURLとオプションのサブプロトコル配列のみを受け付けるため、オープニングハンドシェイクにカスタムのAuthorizationヘッダーを付加するAPIは存在しません。実用的な代替手段は4つあります。クエリ文字列にトークンを含める方法、ブラウザが自動的に送信するセッションクッキーを使用する方法、Sec-WebSocket-Protocolサブプロトコル値にエンコードされた認証情報を含める方法、または接続開始後の最初のメッセージとして認証情報を送信する方法です。Node.jsクライアントなどブラウザ外のネイティブWebSocketライブラリは任意のヘッダーを設定できますが、ブラウザのコードからは設定できません。

ファーストメッセージ認証が完了する前に送信されたメッセージはどうなりますか?

AUTHハンドシェイクが完了する前に送信されたメッセージは、クライアント側でキューに入れ、サーバーが認証を確認した後にのみフラッシュする必要があります。それ以前に送信されたメッセージは無音で失われる可能性があるためです。サーバーの認証タイムアウト(通常5〜10秒の範囲)が有効な認証情報を受信する前に発火した場合、接続は切断され、転送中またはキュー内のメッセージはすべて失われます。低速な接続では、ソケットが開いているように見えながらメッセージにレスポンスが返ってこないという障害が発生します。アプリケーションの送信処理は必ず認証済みフラグの後ろにゲートしてください。

WebSocketトークンはインバンドで更新すべきか、それとも切断・再接続すべきですか?

接続がステートフルなサブスクリプションや再接続時に失われる保留中の操作を保持している場合は、既存のソケット上で新しいトークンを送信するインバンド更新を使用してください。接続がステートレスでクライアントがデータ損失なしに低コストでコンテキストを再構築できる場合は、クローズ&再接続を使用してください。この選択はアーキテクチャ上の決定であり、スタイルの問題ではありません。再接続はサーバー側のサブスクリプション状態を破棄するため、多数のアクティブチャンネルを持つライブフィードでは、インバンド更新によって再サブスクリプションのオーバーヘッドと切断中のイベント損失を回避できます。

トークンの期限切れ後もWebSocketのUIが「接続済み」と表示されるのはなぜですか?

トークンが期限切れになるとサーバーはソケットを閉じ、ブラウザはcloseイベントを発火します。しかし、アプリケーションの状態管理がそのイベントを処理しない場合、UIは接続済み状態を表示し続け、その後のすべてのメッセージが無音で失われます。この障害はサーバーログには記録されず(クローズフレームのみが記録される)、セッションリプレイでは最後の成功メッセージの後にクライアント側の沈黙として確認できます。closeイベントを明示的に処理し、切断をユーザーに通知して再接続をトリガーするようにしてください。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.