Microsoft Entra ID(旧 Azure AD)の On-Behalf-Of(OBO)フローで AADSTS50013「Assertion failed signature validation(key not found)」が出ると、証明書や署名の問題に見えて原因が掴みにくいものです。PHP の API A から API B(Snowflake 連携)をユーザー代理で呼ぶ構成を例に、つまずきやすいポイントと最短の解決策を整理します。
想定する構成(API A → API B → Snowflake)
まず前提を揃えます。本記事で扱うのは「ユーザーの操作で取得したトークンを使い、サーバー側(API A)が下流 API(API B)をユーザー代理で呼ぶ」構成です。クライアント(Web/SPA/ネイティブ)から直接 API B を呼べない、または呼ばせたくない(秘密情報を隠したい、監査や制御を集約したい)場合に OBO が登場します。
ユーザー(ブラウザ/アプリ)
│ (認可コードフロー: scope=API A)
▼
Microsoft identity platform(Entra ID)
└─発行──▶ Token A(aud=API A)
│
│ Authorization: Bearer Token A
▼
API A(PHP Web API / Confidential Client)
│ (OBO: assertion=Token A, scope=API B)
▼
Microsoft identity platform(Entra ID)
└─発行──▶ Token B(aud=API B)
│
│ Authorization: Bearer Token B
▼
API B(Snowflake 連携 API)
│
▼
Snowflake / 関連リソース
ポイントは「Token A は API A 宛てのユーザー アクセストークン」「Token B は API B 宛てのユーザー アクセストークン」という二段構えになることです。
エラーの正体:AADSTS50013 が示すこと
AADSTS50013: Assertion failed signature validation. [Reason – The key was not found.] は、OBO の assertion として渡した JWT を Entra が期待どおりに検証できないときに返りやすいエラーです。文面は「署名検証」「キーが見つからない」と出ますが、実際には次のような“前提崩れ”が混ざって発生します。
- OBO の
assertionに Entra が発行したユーザー アクセストークンではないもの(外部 IdP トークン、自作 JWT、形式の違うトークン)を入れている - Entra 発行トークンでも、OBO が想定する宛先(audience)が違う、またはトークン種別が違う(例:
id_tokenを入れている) client_assertion(証明書署名)を使っている場合に、アプリ登録に紐づくキーと JWT ヘッダーのkid/x5tが一致していない、またはclient_idを取り違えている
このため、エラー文だけで「証明書が悪い」「キーが壊れている」と決め打ちすると遠回りになります。まずは OBO の assertion と client(API A)の関係を確認するのが最短です。
結論:OBO の assertion に入れる Token A は「API A 宛て」である必要がある
今回の典型的な落とし穴はここです。OBO は「ユーザーが API A を呼ぶために持っているトークン(Token A)を根拠に、API A が下流リソース(API B)用のトークン(Token B)を追加で入手する」フローです。
つまり、Token A の aud(宛先)は API A でなければいけません。最初の認可コードフローで API B のスコープを要求して Token A を取ってしまうと、Token A の aud が API B になり、OBO の前提が崩れて失敗しやすくなります。
| 項目 | 失敗しやすいパターン | 正しいパターン |
|---|---|---|
| 最初に取る Token A | API B のスコープを要求して取得(aud=API B) | API A のスコープを要求して取得(aud=API A) |
| OBO の目的 | 「すでに API B 宛てのトークン」を assertion にして交換しようとする | 「API A 宛てのユーザー トークン」を根拠に、API B 宛てトークンを得る |
| OBO の client_id / 資格情報 | API B の client_id を混ぜる / どちらか曖昧 | 常に API A(上流 API)の client_id と資格情報 |
正しい手順:実運用でハマりやすい点も含めた OBO の流れ
認可コードフローでは「API A のスコープ」を要求して Token A を取得する
最初のログイン(/authorize → /token)で要求するのは API A の delegated scope です。API A 側で user_impersonation などのスコープを公開し、それを要求します。
スコープは例として次のようになります(Application ID URI の設定により形式は変わります)。
api://{API_Aのclient_id}/user_impersonation- または
api://{API_AのApplication ID URI}/user_impersonation
認可リクエスト(/authorize)のイメージ:
GET https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize
?client_id={API_Aのclient_id}
&response_type=code
&redirect_uri={redirect_uri}
&scope=openid%20profile%20offline_access%20api://{API_A}/user_impersonation
&state=...
&code_challenge=...
&code_challenge_method=S256
トークン取得(/token)のイメージ(confidential client の場合):
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={API_Aのclient_id}
&client_secret={API_Aのclient_secret}
&grant_type=authorization_code
&code={authorizeで得たcode}
&redirect_uri={redirect_uri}
&scope=api://{API_A}/user_impersonation
この結果として得られる Token A(access_token)は、aud が API A になっていることが重要です。
OBO(Token B 取得)の /token では「API A の資格情報」を使い、scope に「API B」を指定する
次に API A が OBO を実行して Token B を取得します。ここでのポイントは 2 つです。
- client_id / client_secret(または client_assertion)は API A を使う
- scope は下流(API B) を指定する
OBO のリクエスト例(v2 エンドポイント):
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
client_id={API_Aのclient_id}
&client_secret={API_Aのclient_secret}
&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&requested_token_use=on_behalf_of
&assertion={Token A}
&scope=api://{API_B}/access_as_user
このレスポンスで返る access_token が Token B です。以降は API A → API B の呼び出しで Authorization: Bearer {Token B} を付けます。
| パラメータ | 値 | 間違えやすい点 |
|---|---|---|
| grant_type | urn:ietf:params:oauth:grant-type:jwt-bearer | 認可コードフローと混同して authorization_code のままにしない |
| requested_token_use | on_behalf_of | 省略すると通常の JWT bearer grant として扱われ挙動が変わる |
| assertion | Token A(API A 宛てのユーザー access_token) | ID トークンや外部 IdP トークン、自作 JWT を入れない |
| client_id | API A の client_id | 下流の API B を指定してしまう |
| scope | API B の delegated scope(例:api://{API_B}/access_as_user) | API A のスコープを入れたままにする / .default と混同する |
v1 と v2 の違い(scope と resource)
古いサンプルや既存実装では v1 エンドポイント(/oauth2/token)の例が残っていることがあります。混ざるとパラメータを取り違えやすいので、チーム内で「どちらで統一するか」を決めておくと事故が減ります。
| 観点 | v1(旧) | v2(推奨されることが多い) |
|---|---|---|
| 指定するもの | resource(リソース) | scope(スコープ) |
| トークン URL の例 | https://login.microsoftonline.com/{tenant}/oauth2/token | https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token |
| よくある混乱 | resource にスコープ文字列を入れてしまう | scope に .default を入れて delegated と混同する |
なお、.default はアプリ権限(client credentials)で使う文脈が多く、OBO の delegated scope と混ざると切り分けが難しくなります。OBO では「下流 API が公開した delegated scope」を素直に指定するのが安全です。
最重要チェック:Token A の中身(aud / scp)を確認する
OBO のトラブルは、トークンを「発行できた/できない」だけを見ていると迷子になります。Token A をデコードして claims を確認すると原因が一気に絞れます。
最低限、次の項目は確認してください。
| claim | 見る理由 | 期待値(今回の構成) |
|---|---|---|
aud | トークンの宛先。OBO の assertion は上流 API 宛てが前提 | Token A は API A、Token B は API B |
scp / roles | 委任スコープ(scp)かアプリロール(roles)かで権限モデルが変わる | OBO で渡す Token A は通常 scp を持つ |
iss / tid | 発行元テナント。テナント違い・Authority 違いの切り分けに使う | 期待するテナント ID と一致 |
exp | 期限切れだと assertion として使えない | 現在時刻より未来 |
azp / appid | 呼び出し元クライアントの識別。API A の client_id と整合しているか確認 | API A に対応する値 |
Token A の aud が API B になっていたら、まずは 最初の認可コードフローの scope を API A に戻すのが最優先です。
API B 側の設定チェック(基本だけで事故を減らす)
Token A を正しても、API B 側の公開設定が曖昧だと次の段階で詰まります。特に「Application ID URI」「スコープ」「許可(同意)」の 3 点はセットで見ます。
| チェック項目 | 見る場所(概念) | 落とし穴 |
|---|---|---|
| Application ID URI | API B の「Expose an API」 | api://{client_id} か独自 URI か。スコープ指定の文字列と一致していないと、原因が分かりにくい 401/400 に繋がる |
| delegated scope の公開 | API B のスコープ定義 | access_as_user / user_impersonation など。名称は任意だが、委任(delegated)であることが重要 |
| API A への許可付与 | API A の「API permissions」 | API B の delegated permission を追加し、必要なら管理者同意(admin consent)を実施 |
| (任意)事前承認 | API B の「Authorized client applications」 | 同意画面を減らせるが、これだけで権限付与が完了したと誤解しやすい |
運用上は「API B のスコープ文字列を固定し、アプリ登録の Application ID URI を途中で変えない」だけでも事故率が下がります。URI を後から変えると、既存クライアントが要求している scope とズレてデバッグが難しくなります。
PHP(API A)での実装例:OBO の /token を呼ぶ
ライブラリ(MSAL 等)を使う場合でも、最終的にやっていることはトークン エンドポイントへの form POST です。まずは生のリクエストを理解しておくと、ログに出るパラメータや 400 エラーの切り分けが楽になります。
cURL での最小例
curl -X POST "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id={API_A_CLIENT_ID}" \
--data-urlencode "client_secret={API_A_CLIENT_SECRET}" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "requested_token_use=on_behalf_of" \
--data-urlencode "assertion={TOKEN_A}" \
--data-urlencode "scope=api://{API_B_APP_ID}/access_as_user"
PHP(例:Guzzle なし、素の cURL)
<?php
function getOboToken(string $tenantId, string $clientId, string $clientSecret, string $assertionTokenA, string $scope): array
{
$url = "https://login.microsoftonline.com/{$tenantId}/oauth2/v2.0/token";
$postFields = http_build_query([
"client_id" => $clientId,
"client_secret" => $clientSecret,
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
"requested_token_use" => "on_behalf_of",
"assertion" => $assertionTokenA,
"scope" => $scope,
], "", "&");
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/x-www-form-urlencoded"],
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_TIMEOUT => 20,
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException("cURL error: " . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if (!is_array($json)) {
throw new RuntimeException("Token response is not JSON. HTTP {$status}: {$raw}");
}
if ($status >= 400) {
// トークンそのもの(assertion)をログに出さないこと
$err = $json["error"] ?? "unknown_error";
$desc = $json["error_description"] ?? "";
$trace = $json["trace_id"] ?? "";
$corr = $json["correlation_id"] ?? "";
throw new RuntimeException("Token request failed. HTTP {$status}: {$err} {$desc} trace_id={$trace} correlation_id={$corr}");
}
return $json; // access_token, expires_in, token_type など
}
実運用では Token A をログ出力しないこと、レスポンスの expires_in を見て Token B をキャッシュすること、失敗時は trace_id / correlation_id を含めて残すことが重要です。
「今回の決定打」になりやすい原因と、切り分けの順番
OBO の AADSTS50013 は原因が 1 つとは限りません。現場で速く直すための“当たりを付ける順番”を、優先度順に並べます。
| 優先度 | 確認すること | 判断の目安 | 対処 |
|---|---|---|---|
| 高 | Token A の aud | API B になっている / 想定外の URI | /authorize の scope を API A に変更して Token A を取り直す |
| 高 | assertion に入れているのが access_token か | ID トークンを入れている、または外部 IdP の JWT | Entra 発行の access_token(API A 宛て)に差し替える |
| 中 | client_id / 資格情報が API A か | API B の client_id を使っている、または証明書を別アプリに登録している | 「OBO を呼ぶ主体=API A」で統一する |
| 中 | API B の delegated permission が API A に付いているか | 権限不足のエラーに見える / consent 周りが未実施 | API permissions を追加し、必要なら管理者同意 |
| 低 | トークンの期限や時刻ずれ | exp 直前、サーバー時刻がずれている | NTP 同期、キャッシュ戦略の見直し |
特に「最初の scope に API B を入れていた」ケースは、設定やコードを大きく変えずに直せるため、最初に潰す価値が高いです。
関連ケース:外部 IdP(AWS ALB OIDC)のトークンや自作 JWT を assertion にして OBO したい
AWS ALB の OIDC など、Entra ではない外部 IdP がユーザー認証を担っている構成では「外部 IdP のトークン(または自作 JWT)を user assertion として OBO に渡したい」という発想になりがちです。しかし、ここにも前提があります。
一般に OBO の assertion は、Entra が発行し、Entra が検証できるユーザー トークンであることが期待されています。外部 IdP のトークンや自作 JWT を渡しても、Entra 側にその署名鍵や信頼関係がないため、結果として 「署名キーが見つからない」系のエラー(AADSTS50013)になりやすい、というのが現実です。
現実的な落としどころ(設計の選択肢)
要件(本当に“ユーザー代理”が必要か、下流 API が何を要求するか)によって、最適解は変わります。よく採られる選択肢を整理します。
| 選択肢 | 概要 | 向いている要件 | 注意点 |
|---|---|---|---|
| ユーザー認証を Entra に寄せる | ALB の OIDC を Entra に向ける、または外部 IdP を Entra と連携し、最終的に Entra が API A 宛てのトークンを発行する | Graph や下流 API が delegated token 前提、条件付きアクセスや MFA を活かしたい | 既存 IdP の運用・ユーザー属性・多要素認証の整理が必要 |
| OBO をやめてアプリ権限に寄せる | 下流を client credentials(アプリ権限)で呼び、ユーザー情報は監査ログ等で補完する | 下流が「ユーザーのトークン」を必須としない、監査は別手段でも良い | 最小権限設計、監査設計(誰が操作したか)を別レイヤーで担保する |
| 外部トークンを検証して内部の権限制御に変換する | 外部 IdP の JWT を自前で検証し、API A 内で権限制御・監査を完結させる(下流にはアプリ権限でアクセス) | 外部 IdP を維持しつつ、バックエンドで認可を統一したい | 下流が Entra の delegated token を要求する場合は、この選択肢だけでは満たせない |
「外部 IdP の JWT をそのまま OBO の assertion に入れる」方向で粘るより、どこで Entra がトークンを発行する形に寄せるか/あるいは OBO 自体をやめるかを設計として決めた方が、実装も運用も安定します。
補足:client_assertion(証明書署名)で “key not found” になる場合のチェック
OBO とは別に、client_secret の代わりに client_assertion(証明書で署名した JWT)を使う構成で AADSTS50013 が出ることもあります。この場合は「assertion(ユーザー トークン)」ではなく「client_assertion(クライアント認証)」側の問題であることが多いです。
よくある突合ポイントをまとめます。
| チェック | 見る場所 | ありがちなミス |
|---|---|---|
| 証明書をアップロードしたアプリと client_id | アプリ登録(API A) | 別アプリに証明書を登録しているのに、トークン要求は別の client_id で投げている |
JWT ヘッダーの kid / x5t | client_assertion のヘッダー | 登録証明書の thumbprint と一致しない。複数証明書運用で古い thumbprint を使っている |
JWT の aud | client_assertion の claim | Graph や別 URL を入れている。正しくはトークン エンドポイント(v2 の /token) |
| 署名アルゴリズム | client_assertion の header/署名 | RS256 以外、証明書の秘密鍵が違う、PEM/DER 変換で壊れている |
| 時刻(iat/nbf/exp) | client_assertion の claim | サーバー時刻がずれている、exp が長すぎる/短すぎる |
“key not found” という文言は、ユーザー トークンでも client_assertion でも出ます。どちらの assertion を指しているのかを切り分けるには、まず client_secret で試して成功するかを見て、成功するなら証明書周り(client_assertion)に原因が寄っている、と判断すると速いです。
Snowflake 連携での実務ポイント(OBO 以前に詰まりやすい所)
Snowflake 連携が絡むと「認証方式が二段になる」ことが多く、OBO 自体が正しくても運用上の抜けで詰まります。代表的な注意点を短くまとめます。
- ユーザー起点の操作とバッチ/システム起点の操作を分ける:同じ API B でも、ユーザー操作は delegated、バッチは client credentials と分けると事故が減ります
- 監査ログの突合軸を決めておく:API B のログに「呼び出しユーザー(oid 等)」と「Snowflake 側の実行主体」を残し、後から追える形にする
- エラー時の相関 ID を必ず残す:Entra の
trace_id/correlation_id、API B のリクエスト ID、Snowflake 側のクエリ ID を繋げられると調査が速い
運用で効く小技:同意・キャッシュ・監査を一緒に設計する
OBO は「トークンが取れたら終わり」ではなく、運用で次の 3 点が効いてきます。
同意(consent)を減らしつつ、権限は最小化する
- API B の delegated scope は増やしすぎない(Snowflake 連携で必要な操作に絞る)
- 管理者同意が必要な権限は、環境ごと(開発/検証/本番)に実施履歴を残す
- 同意画面が頻発するなら、API B 側の事前承認や、クライアント側のスコープ設計(増分同意)を見直す
Token B は短命前提でキャッシュする
- 同一ユーザー・同一スコープで短時間に何度も OBO を叩くと、遅延と失敗率が上がります
- Token B は
expまでメモリ/分散キャッシュに保持し、API A から API B の呼び出しで使い回す - Token A が失効したら Token B の再取得もできないため、フロント側のセッション設計(再ログイン/リフレッシュ)とセットで考える
「誰がやったか」を下流に残す
- OBO を使う最大の価値は、下流がユーザー コンテキストを持てることです
- API B 側では
oidやpreferred_username等(環境により異なる)を監査ログに残し、Snowflake 操作ログと突合できる形にしておく - 個人情報の取り扱い方針に合わせ、必要最小限の claim だけを記録する
まとめ:AADSTS50013 を最短で潰すための要点
- OBO の assertion に入れる Token A は 「API A 宛てに発行されたユーザー access_token」である必要がある
- 最初の認可コードフローで API B のスコープを要求して Token A を取るとハマりやすい。まず scope を API A に戻して取り直す
- OBO の /token では、client_id / 資格情報は常に API A、scope は下流(API B)
- 外部 IdP のトークンや自作 JWT を assertion にして通す発想は難易度が高い。Entra が最終的にトークンを発行する設計に寄せるか、OBO を使わない設計に切り替える
OBO は一度正しい形に乗せると強力ですが、最初の 1 トークン(Token A)の取り方を間違えると、以降のデバッグが全部ズレます。まずは Token A の aud を見て、上流 API(API A)宛てになっているかを確認してください。

コメント