Microsoft Entra ID の OBO フローで AADSTS50013(Assertion failed signature validation)を解決する方法|PHP/API A・API B・Snowflake 連携

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 の assertionEntra が発行したユーザー アクセストークンではないもの(外部 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 AAPI 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_typeurn:ietf:params:oauth:grant-type:jwt-bearer認可コードフローと混同して authorization_code のままにしない
requested_token_useon_behalf_of省略すると通常の JWT bearer grant として扱われ挙動が変わる
assertionToken A(API A 宛てのユーザー access_token)ID トークンや外部 IdP トークン、自作 JWT を入れない
client_idAPI A の client_id下流の API B を指定してしまう
scopeAPI B の delegated scope(例:api://{API_B}/access_as_userAPI A のスコープを入れたままにする / .default と混同する

v1 と v2 の違い(scope と resource)

古いサンプルや既存実装では v1 エンドポイント(/oauth2/token)の例が残っていることがあります。混ざるとパラメータを取り違えやすいので、チーム内で「どちらで統一するか」を決めておくと事故が減ります。

観点v1(旧)v2(推奨されることが多い)
指定するものresource(リソース)scope(スコープ)
トークン URL の例https://login.microsoftonline.com/{tenant}/oauth2/tokenhttps://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 URIAPI 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 の audAPI B になっている / 想定外の URI/authorize の scope を API A に変更して Token A を取り直す
assertion に入れているのが access_token かID トークンを入れている、または外部 IdP の JWTEntra 発行の 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 / x5tclient_assertion のヘッダー登録証明書の thumbprint と一致しない。複数証明書運用で古い thumbprint を使っている
JWT の audclient_assertion の claimGraph や別 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 側では oidpreferred_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)宛てになっているかを確認してください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次