JWTを保持する開発者のイラスト。JWTが検証サーバーを経て安全な経路に進む様子と、未検証のまま警告付きデータベースへ向かう様子が対比され、デコードと署名検証の重要性を視覚的に伝えています。

はじめに

Edge環境のミドルウェアでJWTを扱うとき、「デコードできる」を「検証済み」と混同してしまうことがあります。

JWTはBase64でエンコードされているだけなので、署名検証をしなければ誰でも中身を読めますし、書き換えることもできます。にもかかわらず、デコードして取り出したsub(ユーザーID)やscopesをそのまま認可判定に使ってしまう実装は、意外と見かけるパターンです。

あるプロジェクトで、Cloudflare Pages Functionsのミドルウェアがまさにこの状態になっていました。この記事では、ローカルデコード依存の認可判定を、認可サーバーへのサーバー間照会に置き換えた設計を、つまづきやすいポイントも含めて整理します。

こんな人におすすめ

  • Cloudflare Workers/PagesなどEdge環境でJWTを扱っている方
  • 「デコードはしているが署名検証はしていない」実装に心当たりがある方
  • サーバー間照会のキャッシュ設計やTTLの決め方に悩んでいる方
  • レート制限のキー選びで、クライアントが操作できる値を使ってしまっていないか不安な方

「デコードできる」と「検証済み」は違う

JWTのペイロードはBase64URLでエンコードされているだけなので、atob相当の処理で誰でも読めます。署名検証をしていない状態でこの値を信用するのは、封筒の中身を見ただけで送り主を信じるようなものです。

対策としてやったことはシンプルで、ローカルデコードの役割を「明らかに不正なリクエストを早期に弾く入口チェック」に限定し、実際のユーザーID確定は認可サーバーへの問い合わせに一本化しました。

さらに、型の面でも古い経路を潰しています。「検証済みユーザー情報」を表す新しい型を作り、「デコードしただけの値」を持っていた既存の型を削除しました。こうすることで、未検証の値をうっかり参照しているコードがコンパイルエラーとして検出できます。レビューでの見落としに頼らない安全策として機能しました。

// 変更前: デコードしただけの値を型として持ち回していた
type DecodedToken = {
  sub: string;
  scopes: string[];
};

// 変更後: サーバー間照会で確定した値だけを表す型
type VerifiedUser = {
  id: string;
  scopes: string[];
};

サーバー間照会でユーザーIDを確定する

ユーザーIDが必要なエンドポイントに限り、認可サーバーのセッション確認APIへサーバー間で照会し、その応答だけを信頼する方式に切り替えました。

export async function verifyAccessToken(
  token: string,
  env: Env,
  options: { tokenExp?: number } = {},
): Promise<SessionVerificationResult> {
  const cacheKey = await hashToken(token); // トークン原文はキーに残さない
  const now = Date.now();

  const cached = sessionCache.get(cacheKey);
  if (cached) {
    if (cached.expiresAt > now) {
      return { ok: true, user: cached.user };
    }
    sessionCache.delete(cacheKey);
  }

  let response: Response;
  try {
    response = await fetch(sessionEndpointUrl, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${token}`,
      },
      signal: AbortSignal.timeout(TIMEOUT_MS),
    });
  } catch (error) {
    return classifyNetworkError(error); // タイムアウト・接続失敗はすべて503扱い
  }

  if (response.status === 401) return failure(401, "SESSION_INVALID");
  if (response.status === 403) return failure(403, "SESSION_FORBIDDEN");
  if (!response.ok) return failure(503, "AUTH_UPSTREAM_UNAVAILABLE");

  const user = extractVerifiedUser(await response.json());
  if (!user) return failure(503, "AUTH_UPSTREAM_INVALID_RESPONSE");

  storeInCache(cacheKey, user, now, options.tokenExp);
  return { ok: true, user };
}

認可サーバーの応答から取り出すのはidscopesだけです。応答に再発行トークンのような余計な情報が含まれていても、意図的に読み捨てています。使う値を最小限に絞ることも、この手の実装では地味に効きます。

ミドルウェア側では、検証済みユーザー情報が確定するまで、後続処理はそれを待つ形にしています。

if (needsVerifiedUser) {
  const verification = accessToken
    ? await verifyAccessToken(accessToken, env, { tokenExp: accessTokenExp })
    : ({ ok: false, status: 401, code: "SESSION_INVALID" } as const);

  if (!verification.ok) {
    return createSessionVerificationErrorResponse(verification);
  }

  data.verifiedUser = verification.user; // 以降のハンドラーはこれだけを信頼する
}

保存先パスの決定など、実際にIDを使う箇所でも、リクエスト側の値は「一致確認」にしか使わず、信頼するのは検証済みの値だけ、という役割分担を徹底しました。

// 変更前: リクエストに含まれるトークンのuserIdをそのまま保存先に使っていた
// 変更後: サーバー間照会で確定したIDだけを使い、リクエスト側の値は一致確認にしか使わない
if (permitPayload.userId !== verifiedUser.id) {
  return createErrorResponse("User ID mismatch", 403, "USER_MISMATCH", origin);
}
const storagePath = generateStoragePath(SERVICE_NAME, verifiedUser.id, resourceType);

キャッシュキーとTTL、fail-closedの設計

サーバー間照会は毎回発生させるとレイテンシとコストの両面で厳しいので、isolateメモリにキャッシュしています。ここでの判断ポイントは主に3つです。

1つ目は、キャッシュキーにトークン原文を使わないことです。トークンのSHA-256ハッシュをキーにしています。原文をそのままメモリに保持し続けるのは、漏洩経路を増やすだけなので避けました。

2つ目は、TTLの上限です。60秒以下、かつトークン自体の有効期限(exp)を超えないようmin(60秒, exp)で計算しています。認証エラーや障害時のレスポンスはキャッシュしません。

3つ目は、fail-closedの徹底です。認可サーバーへの照会がタイムアウト・5xx・想定外のレスポンス形式だった場合は503を返して拒否します。ネットワーク越しの検証に依存している以上、「検証できない=通す」ではなく「検証できない=拒否する」という方針を崩さないようにしました。

エラーコードも状態ごとに分けています。「トークン不正」「認可サーバーからの拒否」「認可サーバー自体の障害」を別コードにしておくと、障害調査のときに「クライアント側の問題か、依存先の問題か」を一目で判別できます。

レート制限のキー選びを見直す

サーバー間照会は認可サーバー側にも負荷をかけるので、IPベースのレート制限のに置きました。偽造トークンを大量に送りつけられても、その負荷が認可サーバーまで届かないようにするためです。

もうひとつ見直したのが、レート制限のキーそのものです。別のエンドポイントでは、セッションCookieの値をそのままレート制限のキーに使っていました。しかしCookieの値はクライアントが自由に付け替えられるため、ハッシュ化したところで制限を回避できてしまいます。IPアドレスをキーにする方式に統一しました。

// NG: クライアントが書き換え可能な値をキーにしている
const rateLimitKey = hashValue(sessionCookie);

// OK: クライアントが操作できない値をキーにする
const rateLimitKey = getClientIp(request);

つまづきやすいポイント

  • 「デコードだけで判定している箇所」は横断的に潜んでいる: 1箇所直しても、似た実装が別のエンドポイントに残っていることがあります。棚卸しは一度で終わらせず、継続的に見直す前提で計画するのがおすすめです。
  • キャッシュTTLは短くしすぎても長くしすぎても問題が出る: 短すぎると照会回数が増えてレイテンシが悪化し、長すぎると失効済みトークンが一定時間通ってしまいます。expとのmin計算で上限を作るのは有効な落とし所でした。
  • 既存データのID形式との整合性確認を忘れない: 認可の経路を変えても、保存済みリソースのパスなどに使われているID形式が変わってしまうと、既存データへのアクセスが壊れます。切り替え前に形式が一致するか必ず確認しておく必要があります。
  • 使い切り型トークンの完全な失効チェックは別問題として残りやすい: 一度使ったら無効になるべきトークンを、発行元に「消費せずに照合するAPI」がないまま検証している場合、自作・使い回し・失効済みトークンを完全には弾けません。これは今回のスコープでは解決しきれず、継続課題として残しています。

まとめ

JWTのローカルデコードは、あくまで「形式が壊れていないかの入口チェック」に留め、実際の認可判定はサーバー間照会など検証済みの経路に一本化するのが安全です。

キャッシュキーにトークン原文を使わない、TTLをexpとのminで決める、fail-closedを徹底する、レート制限のキーにクライアントが操作できる値を使わない——どれも単体では地味な判断ですが、積み重ねることで認可の信頼境界がはっきりします。

型で古い経路を消す、というアプローチも、レビュー頼みにしない安全策として汎用的に使える手法だと感じました。