Claude Media
Claude CodeにJWT検証コードをレビューさせる — alg・aud・expの抜けを洗う

Claude CodeにJWT検証コードをレビューさせる — alg・aud・expの抜けを洗う

読み取り専用のサブエージェントにRFC 8725とRFC 7519の観点を渡し、自社アプリのJWT検証コードからalg=none許容・署名未検証・aud/iss未確認・expの許容幅・kid解決の抜けを洗う手順です。

JWTの検証コードは、ログインが通れば「動いている」ように見えます。壊れていても、正しいトークンは通り続けるからです。レビューで確かめるべきは逆側で、通ってはいけないトークンが本当に弾かれるかどうかです。

この記事では、検証専用の読み取り専用サブエージェントをClaude Codeに用意し、自社アプリのJWT検証コードを洗わせる手順を示します。観点はRFC 8725(JWT Best Current Practices)とRFC 7519の条文から取り、指摘のたびに再現テストを書かせて、本当に抜けているかを確かめます。

検証コードのレビューは「通ってはいけないもの」の列挙から始める

レビュー役に「JWT検証を見て」とだけ頼むと、返ってくるのは一般論の寄せ集めになりがちです。コードに対して具体的に何を探すのかを、先に観点として渡しておきます。

観点の出どころはRFCです。RFC 8725は、JWTの実装と運用で知られている攻撃を挙げ、それぞれに対策を結びつけています。たとえば署名アルゴリズムの扱いについて、次の攻撃を挙げています。

  • 攻撃者が alg を none に書き換え、署名を確かめないまま「検証済み」にしてしまうライブラリがある
  • RS256 を HS256 に書き換え、RSA公開鍵をHMACの共有秘密として署名を検証してしまう実装がある

どちらも、検証側が「トークン自身が名乗るアルゴリズム」を信じたときに成立します。コードレビューでは、検証関数に許可するアルゴリズムが固定されているかを見れば足ります。

もうひとつ、別記事で扱っている話と混ざらないよう線を引いておきます。セルフホスト環境のセッショントークンを検証する手順は、Claude Code側が発行するJWTを自社サービスで確かめる話です。この記事の対象は、自社アプリが自分で発行し検証しているJWTの実装です。

RFCの条文をレビュー観点の表にする

サブエージェントに渡す観点は、条文と検索のヒントをセットにすると効きます。次の表が、この記事で使う観点の一覧です。

観点根拠コードで探すもの
アルゴリズムの固定根拠RFC 8725 §3.1コードで探すもの検証呼び出しにアルゴリズムの許可リストがあるか。none を許していないか
署名の検証根拠RFC 8725 §3.3コードで探すもの署名検証をせず、本文だけを取り出す関数でクレームを信じていないか
発行者と対象者根拠RFC 8725 §3.8、§3.9コードで探すものiss と aud を期待値と突き合わせているか
有効期限根拠RFC 7519 §4.1.4コードで探すものexp を見ているか。許容する時計ずれが数分以内か
鍵の解決根拠RFC 8725 §3.10コードで探すものkid をそのままSQLやパスに使っていないか。jku や x5u を無条件に辿っていないか
トークンの取り違え根拠RFC 8725 §3.11、§3.12コードで探すものtyp や aud で、別用途のJWTを受け入れない作りか

各行は、条文が実際に述べている内容に沿っています。順に確かめます。

アルゴリズム、署名、発行者、対象者

RFC 8725 §3.1は、ライブラリが呼び出し側に「サポートするアルゴリズムの集合」を指定させ、それ以外を使ってはならないと定めています。さらに、1つの鍵は厳密に1つのアルゴリズムで使い、その点も暗号操作のときに確かめることを求めています。

§3.8は、iss クレームがあるなら、署名に使う鍵がその発行者のものだと確かめ、そうでなければ拒否することを求めています。§3.9は、同じ発行者が複数の受け手向けにJWTを出せるなら、aud を持たせ、受け手は自分に向けたものかを確かめて、無い場合や自分と結びつかない場合は拒否することを定めています。

有効期限と時計ずれ

exp はRFC 8725ではなくRFC 7519 §4.1.4の領分です。現在時刻が exp より前でなければ受け付けてはならず、時計ずれのために小さな猶予を持たせてもよいが、通常は数分以内とされています。猶予に数時間を指定した検証コードは、この観点で指摘されます。

鍵の解決と取り違え

RFC 8725 §3.10は、kid が鍵の検索に使われることを前提に、SQLやLDAPのインジェクションにならないよう値を検証または無害化することを求めています。jku や x5u のヘッダーは任意のURLを含みうるため、無条件に辿るとSSRFにつながります。許可する場所の一覧と突き合わせ、取得時にCookieを送らない対策が例として挙げられています。

§3.11と§3.12は、ある種類のJWTが別の種類として受け入れられる事態を防ぐ話です。typ の値、必須クレーム、鍵、aud、発行者のいずれかを変えて、検証規則が互いに排他になるようにします。

読み取り専用のjwt-reviewerサブエージェントを置く

観点が決まったら、レビュー専用のサブエージェントを .claude/agents/ に置きます。サブエージェントの定義は、YAMLのfrontmatterと本文のシステムプロンプトで構成します。必須なのは name と description だけです。

公式ドキュメントのcode-reviewerの例は Read, Grep, Glob, Bash を許可しています。JWTの検証レビューでは、コードを読んで指摘を返すだけなので、Bash も外して Read, Grep, Glob に絞る構成にします。tools に並べたものだけが使える許可リストなので、Edit系やWrite系のツールは最初から持ちません。

---
name: jwt-reviewer
description: JWTの検証コードを読み取り専用でレビューする。JWTの検証関数、認証ミドルウェア、JWKS取得処理を変更または追加したときに使う
tools: Read, Grep, Glob
model: inherit
---
 
あなたはJWT検証コードのレビュー担当です。コードは変更しません。
 
次の観点で、指定された範囲のコードを読み、抜けを探します。
1. 検証にアルゴリズムの許可リストがあるか。noneを許していないか
2. 署名を検証せずにペイロードを信じている経路がないか
3. issとaudを期待値と突き合わせているか
4. expを見ているか。許容する時計ずれが数分以内か
5. kidをSQLやファイルパスに使っていないか。jkuとx5uを辿っていないか
6. 用途の違うJWTを受け入れない作りか(typ、aud、鍵の分離)
 
指摘ごとに次を返します。
- ファイル名と行番号
- どの観点の抜けか
- 抜けていると判断した根拠(コードの該当箇所)
- 再現テストの案(偽造トークンの作り方と、期待する拒否結果)
- 観点が満たされている箇所は、満たしている根拠を一行で
 
読んだ範囲に検証コードが見つからない場合は、見つからなかったと報告します。

model: inherit はメイン会話と同じモデルを使う指定です。省略したときのモデル選択の順序は公式ドキュメントの「Choose a model」に書かれているため、固定したい場合はここで指定します。

出力の最後の項目に意味があります。「満たしている箇所の根拠」を書かせると、レビュー役が実際にコードを読んだかどうかが、返ってきた報告から判断しやすくなります。

メイン会話のpermission modeが bypassPermissions や acceptEdits、autoモードのときは、サブエージェント側の permissionMode の指定は無視され、メイン会話のモードで動きます。読み取り専用を権限モードで担保したい場合は、この継承の規則を先に確認してください。この記事の構成は、tools の許可リストで担保しているため、モードに左右されません。

検証コードのレビューを依頼する

サブエージェントは、説明文を元にClaudeが自動で委譲することも、名前を指定して呼ぶこともできます。確実に動かしたいときは、プロンプトでサブエージェント名を出すか、@ で選んで指定します。@ でのメンション指定は、そのサブエージェントが1回の作業で必ず動くことを保証する方法です。

@jwt-reviewer src/auth/ のJWT検証を見て。署名検証とaud/iss、exp、kidの解決を重点的に。

観点はサブエージェントの本文に書いてあるので、依頼文は範囲と重点だけで足ります。リポジトリが大きいときは、検証関数の置き場所を指定するほど、探索の時間が短くなります。

抜けが出やすい箇所をコードで確かめる

ここからは、レビューで実際に指摘される形のコードを使います。次のNode.jsの例は、この記事用に作った架空のコードで、抜けを5つ含みます。

// auth.js(レビュー対象の例。意図的に抜けを含めている)
import jwt from "jsonwebtoken";
 
const keyStore = new Map(); // kid -> 公開鍵(PEM)
 
function decodePayload(token) {
  // 署名を検証せず、ペイロードをbase64urlで読むだけの関数
  const [, body] = token.split(".");
  return JSON.parse(Buffer.from(body, "base64url").toString());
}
 
export function currentUser(token) {
  const claims = decodePayload(token); // (1) 署名未検証のまま利用
  return claims.sub;
}
 
export function verifyToken(token) {
  const header = JSON.parse(
    Buffer.from(token.split(".")[0], "base64url").toString()
  );
  const key = keyStore.get(header.kid); // (2) kidの形式を見ていない
  return jwt.verify(token, key, {
    algorithms: [header.alg],           // (3) トークン自身が名乗るalgを許可
    clockTolerance: 3600,               // (4) 1時間の時計ずれを許容
    // (5) audience と issuer を指定していない
  });
}

サブエージェントが拾うべき箇所は、コメントの番号と対応します。

  1. currentUser は署名を検証せずにペイロードを信じています。sub が書き換えられた偽トークンでも、そのまま通ります
  2. verifyToken は、kid を鍵の検索に使っています。ここが Map なので注入にはなりませんが、鍵がDB検索やファイルパスに置き換わった瞬間にRFC 8725 §3.10の論点になります
  3. algorithms: [header.alg] は、攻撃者が選んだ値を許可リストにしてしまう書き方です。§3.1の「呼び出し側が指定した集合のみ」を満たしません
  4. clockTolerance: 3600 は、RFC 7519 §4.1.4が通常は数分以内とする許容幅を大きく超えます
  5. audience と issuer の指定が無いため、同じ発行者が別のアプリ向けに出したトークンも受け入れます

修正例

指摘に沿って直すと、たとえば次の形になります。オプション名はjsonwebtokenのものなので、使うライブラリのバージョンのREADMEで確認してください。

const EXPECTED_ALG = "RS256";
 
export function verifyToken(token) {
  const header = JSON.parse(
    Buffer.from(token.split(".")[0], "base64url").toString()
  );
  if (header.alg !== EXPECTED_ALG) throw new Error("alg not allowed");
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(header.kid ?? "")) {
    throw new Error("invalid kid");
  }
  const key = keyStore.get(header.kid);
  if (!key) throw new Error("unknown kid");
  return jwt.verify(token, key, {
    algorithms: [EXPECTED_ALG],
    audience: "https://api.example.com",
    issuer: "https://auth.example.com",
    clockTolerance: 60,
  });
}

currentUser は verifyToken の戻り値から sub を取る形に直します。検証関数を通さない decodePayload は、sub を読む用途に使わないことをレビューで確認します。

指摘を再現テストで確かめる

サブエージェントの報告は、そのまま信じず、テストで確かめます。ここでの再現テストは、偽造トークンを作って検証関数に渡し、拒否されることを確認するものです。

alg=none のトークンは、ヘッダーとペイロードを自分で組み立てれば作れます。末尾の署名部分は空にします。

// auth.test.js
import test from "node:test";
import assert from "node:assert/strict";
import { verifyToken } from "./auth.js";
 
const b64 = (obj) =>
  Buffer.from(JSON.stringify(obj)).toString("base64url");
 
test("alg=none のトークンは拒否される", () => {
  const header = { alg: "none", typ: "JWT", kid: "k1" };
  const body = {
    sub: "attacker",
    iss: "https://auth.example.com",
    aud: "https://api.example.com",
    exp: Math.floor(Date.now() / 1000) + 600,
  };
  const token = `${b64(header)}.${b64(body)}.`;
  assert.throws(() => verifyToken(token));
});
 
test("audが別アプリ向けのトークンは拒否される", () => {
  // 正規の鍵で署名した別audのトークンを、テスト用鍵で用意して渡す
});

二つ目のテストは骨組みだけを示しています。正規の鍵で署名するには、テスト用の鍵ペアを用意して keyStore に登録する必要があるためです。ここまで書かせるのは、メイン会話の役目です。サブエージェントは読み取り専用なので、報告に含まれる再現テストの案を、メイン会話のClaudeがファイルに書き、実行します。

node --test auth.test.js

修正前のコードで、テストが失敗する(トークンが通ってしまう)ことを先に確かめます。修正後に同じテストが成功すれば、指摘が実害のある抜けだったことと、直ったことの両方を示せます。テストが修正前から成功していた場合は、サブエージェントの指摘が誤検知だったことになります。

時計ずれと鍵の入れ替わりをどう見るか

exp の許容幅は、検証コードの中でも数字が直接書かれる箇所です。レビューでは「何秒か」より「根拠があるか」を聞きます。RFC 7519が示すのは「数分以内」という目安で、アプリごとの値は、サーバー間の時刻同期の運用に左右されます。サブエージェントに許容幅を判断させるのではなく、設定値と目安の食い違いを報告させ、判断は人が行います。

鍵の入れ替わりは、RFC 8725の対象外です。RFC 8725に「鍵ローテーション」の語はなく、手順も書かれていません。そのため、この観点は条文ではなく運用の観点で、プロンプトに足します。

  • 検証側が kid から複数の公開鍵を引き分けているか。1つの固定鍵だけを前提にしていないか
  • 古い鍵を取り除いたとき、その鍵で署名された未失効のトークンがどう扱われるか
  • 取得した鍵の一覧をキャッシュしている場合、未知の kid が来たときに再取得するか

最後の項目には注意が要ります。未知の kid のたびに外部へ取得に行く実装は、攻撃者がでたらめな kid を送り続けるだけで、鍵の配布元に負荷をかけます。再取得の頻度に上限があるかも、観点に入れておくと拾えます。

運用観点を足すときは、条文に基づく観点と混ざらないよう、プロンプトの中で見出しを分けて書いておきます。

レビューの結果を鵜呑みにしない

サブエージェントの報告には、二つの限界があります。

一つは、読んでいない場所は見えないことです。jwt.verify の呼び出しだけを見て「問題なし」と返すのは、検証関数の外で decodePayload のような関数が使われているケースを見落とす典型です。依頼文で範囲を絞りすぎないこと、報告の最後に「読んだファイルの一覧」を出させることが、対策になります。

もう一つは、同じモデルが書いたコードを同じモデルが見る場合の盲点です。書いた側が見落とした観点は、レビュー側も見落としやすくなります。別の文脈で差分を検証させる敵対的レビューの考え方を併用すると、見落としの相関が下がります。

脆弱性の検出という観点では、組み込みのsecurity-reviewコマンドや、静的解析との使い分けもあります。security-reviewとCodeQLの違いは、そのどちらを先に回すかの判断材料になります。JWTのような「仕様の読み解きが要る」検証には、この記事のように観点を固定して渡すレビューが向きます。

サブエージェントの最終報告には、Claude Codeが出力をスキャンする仕組みもあります。報告に [harness: で始まる行が付いても、内容が改変されたわけではありません。意味はサブエージェント出力スキャンの記事にまとめています。

まとめ

JWT検証のレビューは、正しいトークンが通るかではなく、通ってはいけないトークンが弾かれるかで組み立てます。観点はRFC 8725とRFC 7519の条文から取り、Read, Grep, Glob だけを持つサブエージェントに渡せば、コードを書き換えられない状態でレビューを回せます。

指摘は報告のまま受け取らず、偽造トークンの再現テストで確かめます。修正前に失敗し、修正後に成功するテストが残れば、次にコードが変わったときのレビューも同じテストで担保できます。鍵ローテーションのように条文が無い観点は、運用の観点として別枠で足し、判断を人に残します。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. datatracker.ietf.org/doc/html/rfc8725
  3. datatracker.ietf.org/doc/html/rfc7519
この記事を共有:XはてブLinkedIn