Ignite 2025 にて Application Gateway の JWT Validation がパブリックプレビューとして出てきました。これにより Application Gateway にて Microsoft Entra ID によって発行された JSON Web Token (JWT) を検証してバックエンドへのリクエストの転送を許可、拒否できるようになります。
curl による簡易な検証と静的アプリを使ったユーザー認証の両方を試して、ハマりどころや気になる点をまとめてみましたので、ご利用の際はご一読いただけると幸いです。
構成図
前提
- Application Gateway v2 をデプロイ済み
- バックエンド用の Web サーバー (VM) を構築済み
- Application Gateway にてルールを構成済みでブラウザ経由で https にて Application Gateway にアクセス可能
- 外部からアクセス可能 (ファイアウォール制限のない) な Blob Storage が作成済みであること
検証1 (curl で動作確認)
Entra アプリの作成
Entra の画面からアプリケーションの登録を行います。ここで作成するアプリ (API) により、適切な JWT を発行できるようになり、Application Gateway 側も “このクライアント ID” から発行されたトークンの場合は許可するといったことができるようになります。
Microsoft Entra ID の画面から "アプリの登録"-> "新規作成" をクリックします。

"名前" を入力し、"この組織ディレクトリのみに含まれるアカウント" を選択して、"登録" をクリックします。

作成されたアプリの "アプリケーション (クライアント) ID" と "ディレクトリ (テナント) ID" をコピーしておきます。

"API の公開" -> "アプリケーションの URI" の "追加" をクリックし、"api://<クライアント ID>" が表示されるので、そのまま保存します。

"API の公開" -> "Scope の追加" をクリックし、以下のような形で入力して保存します。
| 項目 | 値 |
|---|---|
| スコープ名 | access_as_user |
| 同意できるのはだれですか? | 管理者とユーザー |
| 管理者の同意の表示名 | Access MyWebAPI |
| 管理者の同意の説明 | Allow the app to access MyWebAPI |
シークレットの作成 (オプション)
動作確認として、このアプリに対して curl コマンドでトークンを取得できるようにシークレットの設定も行います。ユーザー認証を使う場合はシークレットは不要です。
"証明書とシークレット" -> "新しいクライアントシークレット" をクリックし、"説明" を入力して "追加" をクリックします。

Application Gateway の設定
公開情報にある通り App Gateway JWT 構成ポータル のリンクから Azure Portal にログインします。
上記のリンクを経由することで左のペインに JWT validation configurations という項目が追加されます。
"Add JWT validation configuration" をクリックして、各項目を入力します。
| 項目 | 値 |
|---|---|
| Name | 適当な名前 |
| Tenant ID | 先ほどコピーしたテナント ID |
| Client ID | 先ほどコピーしたアプリケーション (クライアント) ID |
| Audiences | api://<上記の Client ID> |
関連付けるルールを選択して、"Add" をクリックします。※現状、JWT validation はルール単位で有効となるため、パス単位では制御できません。

curl コマンドにて動作確認
Powershell & curl.exe を使ってトークンを取得します。Tenant ID と Client ID は先ほどの Application Gateway に設定したものと同じものを設定してください。シークレットは事前にコピーしておいたものを利用します。
$response = curl.exe -X POST https://login.microsoftonline.com/<Tenant ID>/oauth2/v2.0/token `
-H "Content-Type: application/x-www-form-urlencoded" `
-d "grant_type=client_credentials" `
-d "client_id=<Client ID>" `
-d "client_secret=Gmexxxxxxxxxxxxxxxxxxxxt0mahatS" `
-d "scope=api://<Client ID>/.default"
$token = ($response | ConvertFrom-Json).access_token
まずはトークンのヘッダー無しで、Status 401 にて拒否されることを確認します。
curl.exe https://testxxx2.xxxnet2.org/
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>Microsoft-Azure-Application-Gateway/v2</center>
</body>
</html>
次にトークンを付けてアクセスしてみます。
curl.exe -v -H "Authorization: Bearer $token" https://testxxx2.xxxnet2.org/
~~省略~~
< HTTP/1.1 200 OK
< Date: Thu, 27 Nov 2025 07:57:11 GMT
< Content-Type: text/html
< Content-Length: 7
< Connection: keep-alive
< Server: Apache/2.4.58 (Ubuntu)
< Last-Modified: Tue, 25 Nov 2025 08:14:58 GMT
< ETag: "7-64466e0253999"
< Accept-Ranges: bytes
<
AppVM1
* Connection #0 to host testagv2.hiyamanet2.org left intact
Status 200 となり、正常にアクセスできることを確認できました。
気になる点
JWT validation が機能することはこの方法で確認できますが、実利用を考えるとユーザー認証+認可も行いたいところです。
Application Gateway には認可を行う機能はない (有効なトークンであればすべて許可してしまう) ため、他の方法にて実装する必要があります。
そこで Single Page Application (SPA) を Storage 上に用意して、ユーザー認証を行い、そこでトークンを取得してから Application Gateway にアクセスさせるということをやりたいと思います。
SPA の環境は何でもよいですが、用意が簡単だったのでストレージを使っています。
検証2 (SPA + ユーザー認証)
構成図
Azure Storage にて静的 Web サイトの設定
Blob Storage の "静的な Web サイト" の画面にて "静的な Web サイト" を有効化します。
インデックス ドキュメント名を "index.html" と入力して、保存します。
プライマリ エンドポイントをコピーしておきます。

SPA 用の Entra アプリの作成
Microsoft Entra ID の画面から "アプリの登録"-> "新規作成" をクリックします。

"名前" を入力し、"この組織ディレクトリのみに含まれるアカウント" を選択して、"シングルページアプリケーション" を選択後、先ほどコピーした静的な Web サイトの URL をコピペして、"登録" をクリックします。

"認証" のページにて "URI の追加" をクリックして、Application Gateway の URL も追加して、"保存" をクリックします。

Azure Storage に SPA のアップロード
適当なエディタにて以下の SPA アプリ用のコードを記載します。生成 AI で作った簡易なアプリです。
修正するところは <script> の下にある SPA_CLIENT_ID, APPGW_BASE_URL, TENANT_ID, API_APP_ID の 4 つです。
Web サーバーへは /api/hello でアクセスしてますので、Web サーバー側もアクセスできるようにしておきます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>JWT → AppGW テスト</title>
<script src="https://alcdn.msauth.net/browser/2.35.0/js/msal-browser.min.js"></script>
</head>
<body>
<h1>JWT → AppGW テストページ</h1>
<button id="login">① ログインしてトークン取得</button>
<button id="call-api" disabled>② AppGW 経由で API 呼び出し</button>
<pre id="output" style="white-space: pre-wrap; background: #f3f3f3; padding: 16px; margin-top: 20px;"></pre>
<script>
const SPA_CLIENT_ID = "<SPA 用の Entra アプリの Client ID>";
const APPGW_BASE_URL = "https://testxxx2.xxxnet2.org/";
const TENANT_ID = "69817bd9-xxxx-xxxx-a096-a7018e27f302";
const API_APP_ID = "最初に作った Entra アプリの Client ID";
const API_SCOPES = ["api://" + API_APP_ID + "/access_as_user"];
const msalConfig = {
auth: {
clientId: SPA_CLIENT_ID,
authority: "https://login.microsoftonline.com/" + TENANT_ID,
redirectUri: window.location.origin
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
let account = null;
let accessToken = null;
function log(title, obj) {
const out = document.getElementById("output");
out.textContent += "\n\n=== " + title + " ===\n";
if (obj !== undefined) {
out.textContent += (typeof obj === "string" ? obj : JSON.stringify(obj, null, 2));
}
}
document.getElementById("login").onclick = async () => {
try {
// ① ログイン
const loginResp = await msalInstance.loginPopup({
scopes: API_SCOPES
});
account = loginResp.account;
log("ログイン成功", { username: account.username });
// ② トークン取得
const tokenResp = await msalInstance.acquireTokenSilent({
account,
scopes: API_SCOPES
});
accessToken = tokenResp.accessToken;
log("Access Token(先頭40文字)", accessToken.substring(0, 40) + "...");
document.getElementById("call-api").disabled = false;
} catch (err) {
console.error(err);
log("ログイン/トークン取得エラー", err);
}
};
document.getElementById("call-api").onclick = async () => {
try {
const res = await fetch(APPGW_BASE_URL + "/api/hello", {
headers: {
"Authorization": "Bearer " + accessToken
}
});
const text = await res.text();
log("API レスポンス", `status=${res.status}\nbody=${text}`);
} catch (err) {
console.error(err);
log("API 呼び出しエラー", err);
}
};
</script>
</body>
</html>
ファイル名を index.html として保存してコンテナーの $web 内にアップロードします。
この状態で静的 Web アプリのエンドポイントにブラウザでアクセスすると以下のような画面が出るはずです。
"ログインしてトークン取得" をクリックして、"ログイン成功" となれば動作しています。
この構成の問題点
この構成で "AppGW 経由で API 呼び出し" をすると Status 401 となります。
この構成では、SPA はストレージ、アクセスしたい Web サイトは Application Gateway という形で、別サイト (別オリジン) 扱いとなりますが、その場合、ブラウザが安全確認のために preflight(OPTIONS) を送ります。しかし、OPTIONS にはトークンを付けられないため、AppGW が JWT validation で拒否してしまいそのあとの処理に進めない状態になります。
Application Gateway 配下に SPA (BLOB Storage) も配置することで、同一オリジンとして扱うことはできますが、JWT validation はルール単位での関連付けとなり、パス単位で制御できないため、SPA にアクセスする時点でトークンを検証してしまい 401 で拒否されてしまいます。
検証3 (Front Door を置いて回避)
回避策の構成案
Azure Front Door を Storage と Application Gateway のフロントに配置し、パスベースで SPA or Application Gateway にアクセスするような構成を試してみます。
Front Door でパスベースのルーティングを行うことで同一オリジンかつ SPA だけは JWT validation を行わないといったことができます。
構成図
フロントドアの構成
Front Door リソースを作成して、パスによってアクセスする配信グループを変えておきます。
Entra アプリ、SPA の修正
Front Door のエンドポイントの URL も SPA 用の Entra アプリのリダイレクト URI に追加しておきます。

BLOB ストレージに配置した index.html 内の以下の URL も Front Door のエンドポイントに変えておきます。
const APPGW_BASE_URL = "https://jwt-ag-hiyama-xxxxxxxxx.b01.azurefd.net/";
うまくいくと、以下のような形で API レスポンスに status=200 と表示されるはずです。

認可を行う方法
ここまででユーザー認証を行い JWT validation を通して Web サーバーにアクセスすることはできましたが、ユーザーの認可は行っていないため同じテナント内のユーザーであればだれでもアクセスできる状態となります。
Application Gateway には認可を行う機能はないため、Entra アプリの方の機能でトークンを取得できるユーザーを制御してみます。
トークンを取得できるユーザーを絞る簡易な認可の方法にはなるため、より細かい制御はバックエンドのアプリでやる必要があります。
Entra ID の画面からエンタープライズアプリケーションをクリックします。

"割り当てが必要ですか?" を "はい" に変更して、保存します。

この設定で "ユーザーとグループ" のところにいないユーザーはトークンの取得ができなくなります。

上記のユーザーとして割り当てられているユーザーの場合、以下のような同意を求められるので許可すると、Web サーバーにアクセスできました。
アプリに対して割り当てがないユーザーの場合、以下のようにトークンを取得できずに失敗しました。
WAF と併用した場合の挙動
WAF が優先される
トークン無しで WAF にて拒否されるリクエストを送ってみると Status 403 となりましたので、WAF が優先されていることがわかります。
SPA を少し修正して、トークンを付けた場合でも確認しましたが、期待通りに拒否されました。

まとめ
SPA の配置やパスベースのルーティング等、少し考慮は必要ですが、レガシーなアプリで VM にホストされているものやオンプレでホストしているアプリでもリバースプロキシ経由 (Application Gateway) 経由で Entra ID 認証を利用することができるようになると感じる機能でした。
アプリ側に認証ロジックを入れる必要がない点は使いどころがあるのではないかと思います。
パスベースで JWT validation を有効化できるようになるとなおよいなと思いました。
















