1つの JWT で複数 service を verify したかったら、python-jose の audience list 仕様の罠を踏
audience must be a string or None
python-jose の jwt.decode に audience の list を渡すと、これが出る。
事前にちゃんと考えて実装した「複数 service で 1 token を verify する仕組み」は、走らせた瞬間にこの 1 行で落ちた。RFC 7519 では aud claim は string でも list でも OK と書いてあるし、複数 audience に対する verify を library 側がよしなにやってくれるだろう、と何の疑いもなく list を audience 引数に渡してた。完全に library 仕様の読みが甘かったやつです。
最終的には python-jose の audience verify を切って自前で intersection チェックする、という割と地味な workaround に着地したんですが、そこに至るまでの判断と「decode と verify を分離する」 という pattern として後から効いてくる部分が面白かったので書きます。
背景: 1 MCP token を複数 service で verify したい
Codens の構成上の話を先に。
Codens には codens-mcp という unified MCP server があって、これ 1 つで Red (auto-fix) / Blue (QA) / Green (PRD) / Purple (orchestration) / Auth の 5 product surface ぶん、合計 31 個の tool を expose してます。Claude Code が Red の tool を呼んだら MCP server は Red backend に HTTP リクエスト投げるし、Green の tool 呼ばれたら Green backend に投げる。client から見ると 1 つの MCP server だけど、後ろでは 5 つの service と話してる、という構造です。
問題は auth で、token は 1 つしか持たせたくなかった。
最初は素朴に「各 service 用の token を発行して、MCP server 側で振り分ければいいじゃん」と考えました。が、これやると token 管理がしんどい。rotation のタイミングが service ごとにズレるし、Claude Code 側に 5 つの token を持たせるのも気持ち悪いし、何より MCP server 自体が「Purple の orchestration を肩代わりしてる文脈で各 service に話す」 という位置付けなので、token も 1 つで一貫してた方が筋がいい。
で、token の aud claim は purple-codens-mcp で発行することにしました。MCP server が自分自身を識別する audience です。これを各 service に持っていって verify させる、という方針。
ここで JWT の audience verify の話になります。各 service には自分の primary audience がある (例: Green backend は green-codens)。Green が aud=purple-codens-mcp の token を受け取ると、当然 default の verify は「お前向けじゃない」 と reject する。当たり前です。
「1 token を N service で verify する」 と書くと簡単そうなんですが、JWT は元々「特定の audience 向けに発行する」 思想の規格なので、複数 audience を許容する verify を library 側がどう実装してるか、ここがちゃんと library 仕様に依存します。後で書く通り、ここを甘く見てたのが今回の話です。
fix v1: OAUTH_ADDITIONAL_AUDIENCES を設定する
最初の方針は素直で、各 service の settings に「primary audience に加えて、追加で受け入れる audience」 を持たせる、というやつでした。
# 各 service の config.py
OAUTH_AUDIENCE: str = "green-codens" # primary
OAUTH_ADDITIONAL_AUDIENCES: list[str] = ["purple-codens-mcp"]
設計としては「primary は自分宛、additional は信頼してる別 issuer/MCP の audience」 という意味付け。将来別の MCP server 経由で呼ばれる service が増えても、この list に追加するだけで済む。
そして verify する側の実装はこんな感じで書きました。
audiences = [self.audience] if verify_audience and self.audience else []
if audiences and settings.OAUTH_ADDITIONAL_AUDIENCES:
audiences.extend(settings.OAUTH_ADDITIONAL_AUDIENCES)
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
audience=audiences if audiences else None,
)
list を作って、それを jwt.decode の audience 引数に渡す。python-jose 側が token の aud と list の各要素を照合して、どれか 1 つでも一致すれば pass、という挙動を期待してました。
実際 PyJWT (こっちは別 library) は audience=["a", "b", "c"] のような渡し方を正式に support してるので、python-jose も同じだろうと信じ込んでた。両方とも有名な JWT library だし、こういう基本的な仕様は揃ってるだろう、というのが思い込みでした。
unit test の段階では mock で audience 検証を skip してたので普通に green になって、「いけたな」 と思って integration test に進んだら、そこで罠を踏みました。
罠: python-jose は list audience を拒否する
走らせた瞬間、こうなりました。
JWTError: audience must be a string or None
これ、token 側の問題じゃなくて、jwt.decode の audience 引数の type validation で蹴られてる error です。python-jose の decode 実装を読みに行くと、audience parameter は string 1 個または None しか受け付けない、という assertion が入ってる。list は渡せない。
これは正直、library 仕様としては結構意外でした。RFC 7519 の 4.1.3 ("aud" Claim) には、
The "aud" (audience) claim identifies the recipients that the JWT is intended for. (...) In the general case, the "aud" value is an array of case-sensitive strings (...). In the special case when the JWT has one audience, the "aud" value MAY be a single case-sensitive string (...).
と書いてあって、token 側の aud が list 取れるのは spec で明記されてる。にも関わらず、python-jose の verifier 側は「verify したい audience は 1 つだけ受け取る」 という API になってる。
これは GitHub issue にも数年前から指摘されてて、「PyJWT は list audience を受け付けるのに python-jose は受け付けない、互換性ない、なんとかしてくれ」 という議論が長く続いてる状態でした。が、library 側の対応は入ってない (執筆時点)。python-jose 自体が active な maintenance mode じゃなくなってきてるという文脈もあって、しばらく解決しなさそう。
ここで方針の分岐がありました。
- python-jose を捨てて PyJWT に乗り換える
- python-jose のまま、自前で audience verify を書く
1 は綺麗だけど、Codens の backend 5 つ全部で auth 周りの依存を入れ替えるのはまあまあ重い。token 生成と verify の細かい挙動差 (algorithm 名の解釈、optional claim の扱い、error class の階層) を全 service で再確認するコストを払いたくなかった。
なので 2 を選びました。python-jose の audience verify を切って、その部分だけ自分で書く。decode 自体はちゃんと python-jose にやらせる。
fix v2: verify_aud を落として manual で intersection を取る
実装はこうなりました。
should_verify_aud = verify_audience and bool(self.audience)
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
options={"verify_aud": False},
)
if should_verify_aud:
allowed_audiences = {self.audience, *settings.OAUTH_ADDITIONAL_AUDIENCES}
token_aud = payload.get("aud")
token_aud_set = (
set(token_aud) if isinstance(token_aud, list)
else {token_aud} if token_aud is not None
else set()
)
if not (token_aud_set & allowed_audiences):
raise InvalidTokenError(
f"Invalid audience: token aud={token_aud!r}, "
f"expected one of {sorted(allowed_audiences)}"
)
ポイントは 3 つあって、まず options={"verify_aud": False} で python-jose 側の audience verify を明示的に off。これで audience 引数自体を渡さなくなるので、type validation で落ちることはなくなる。signature 検証や exp / nbf の verify は default で走り続けるので、危ない手抜きにはならない。
次に、token の aud claim は spec 上 string 1 個 でも list でも来うるので、両方をきれいに扱える形に正規化してる。set に入れちゃえば後の intersection 一発で済むので、ここは set にしました。isinstance(token_aud, list) で分岐してる部分です。
そして allowed 側も set にして、token_aud_set & allowed_audiences で交差を取る。空 set じゃなければ「token の audience のどれか 1 つ以上が allowed list に含まれている」 ことが保証される。これは集合演算 1 回で意味がはっきり書けるので、loop 回して flag 立てるよりだいぶ読みやすい。
error message に token aud=... と expected one of [...] 両方入れてるのは過去の経験で、audience 系の error は「どの token の話で、何を期待してたか」 が両方ないと debug できないからです。token は redact した上で aud claim だけ出す、ぐらいが運用上ちょうどいい。
副次効果として、「decode と verify を分離した」 構造が後から効いてきました。Codens は service 間で micro 単位だけど auth policy が違う部分があって (Auth service だけは scope 検証も厳しめにやる、とか)、verify ロジックを decode から切り離しておくと、service ごとに少しずつ違う policy を後付けで足しやすい。最初は仕方なく分離したんですが、library quirk に逃げ場所を作るための手段が、結果的に拡張性として効いた感じです。
副次的な学び
python-jose の options dict は知っておくと便利でした。
{"verify_signature": True/False, "verify_aud": True/False, "verify_iat": True/False, "verify_exp": True/False, "verify_nbf": True/False, "verify_iss": True/False, "verify_sub": True/False, "verify_jti": True/False, "require_aud": True/False, ...}
みたいに、各 claim の verify を個別に on/off できる。今回みたいに「signature と exp は library に任せて、audience だけ自前」 という mix-and-match ができるのは大きい。何でも standard library に任せてると library quirk に当たった時に動けなくなるので、library 提供の hook で claim ごとに分解できるかは、JWT 系 library を選ぶ時のチェックポイントとして覚えておこうと思いました。
逆に PyJWT みたいに list audience を最初から受け付ける library を選ぶなら、こういう workaround は要らない。今回は乗り換えコストとの天秤で python-jose 続投を選んだだけで、新規 project なら PyJWT も普通に選択肢に入ります。「FastAPI tutorial が python-jose を使ってるから」 という理由だけで選び続けるのは、こういう quirk を踏んだ後だと考え直したくなる部分です。
〆
JWT の audience verify は spec 上「string でも list でも OK」 になってるので、感覚的には library 側もそれに合わせて多 audience を扱えそうに見える。でも実際は library ごとに微妙に違うし、特に python-jose みたいに古めの library は spec のサブセットしか cover してなかったりする。
multi-service で 1 token を verify したいケースは、SaaS 系の architecture では結構普通に出てくる。N service ぶん token を別々に発行するより rotation も少ないし、agent から各 service に話す時の認証も一貫させやすい。なので「1 token + multi audience」 という方針自体は引き続き有効だと思ってます。ただし library がそれをそのまま助けてくれるとは限らない。
decode と verify を分けておくと、library quirk が降ってきた時の逃げ道が増える。標準 lib の API 1 個で済む部分を分解するのは一見冗長に見えるけど、後から claim ごとに policy を変えたくなった時にもこの構造はそのまま使えるので、決して無駄ではないなと。
Codens では同じ pattern を auth service 全体で踏襲してます。興味あれば LP どうぞ: https://www.codens.ai/
Discussion