← Back to list

Azure API Management : OAuth 2.0 with Microsoft Entra ID

End-to-end guide: protecting an API with validate-azure-ad-token and enabling the developer portal OAuth 2.0 test console.

Umar Khan · 2026-07-10 04:56 · 1 claps · 7.5 min read paywalled
#azure #api-management #oauth2 #developer-portal
Open on Medium ↗
Wiki topics: BIZ · Business Strategy ☁️ · DevOps & Cloud

Azure API Management : OAuth 2.0 with Microsoft Entra ID

End-to-end guide: protecting an API with validate-azure-ad-token and enabling the developer portal OAuth 2.0 test console.

This guide documents how to protect an API in Azure API Management (APIM) using Microsoft Entra ID (OAuth 2.0), validate tokens at the gateway with the validate-azure-ad-token policy, test the setup with a script (no portal required), and finally enable the developer portal’s built-in OAuth 2.0 flow so consumers can acquire tokens interactively instead of pasting bearer tokens manually.

Architecture at a glance

┌─────────────────────┐        1. request token         ┌──────────────────────┐
│  Consumer            │ ──────────────────────────────▶ │  Microsoft Entra ID  │
│  (script / SPN /     │ ◀────────────────────────────── │  (OAuth 2.0 server)  │
│   developer portal)  │        2. access token          └──────────────────────┘
└─────────┬───────────┘
          │ 3. API call with Authorization: Bearer <token>
          ▼
┌────────────────────┐        4. validate-azure-ad-token policy:
│  API Management      │           signature · expiry · issuer/tenant ·
│  gateway             │           audience · client application id
└─────────┬───────────┘
          │ 5. forwarded only if token is valid
          ▼
┌─────────────────────┐
│  Backend API         │
└─────────────────────┘

Two identities are registered in Entra ID:

Environment reference

The concrete values for this deployment (identifiers redacted):

Part 1 : App registrations in Microsoft Entra ID

1.1 Register the backend app (represents the API)

  1. Azure portal → App registrationsNew registration
  2. Name it (e.g. backend-app), choose the supported account type, leave Redirect URI empty → Register
  3. Record the Application (client) ID : this is the token audience
  4. Expose an API → set the Application ID URI (default api://<client-id> is fine)
  5. Add a scope: scope name API.Access; who can consent: Admins and users; fill in display name and description → Add scope

1.2 Set the backend app to issue v2 tokens (critical)

By default, tokens requested against a custom API resource are issued as v1 tokens (ver: 1.0, aud: api://<guid>, issuer sts.windows.net). The validate-azure-ad-token policy configuration in this guide expects v2 tokens (ver: 2.0, aud: <bare guid>, issuer login.microsoftonline.com/<tenant>/v2.0).

In backend-appManifest, set the token version to 2.

New (Microsoft Graph) manifest format : inside the api block:

"api": {
    "requestedAccessTokenVersion": 2
}

Legacy (AAD Graph) manifest format : top level:

"accessTokenAcceptedVersion": 2

Save. Allow a minute or two for propagation.

Symptom if skipped: tokens decode with ver: 1.0 and aud: api://<guid>, and APIM returns 401 Unauthorized due to audience mismatch.

1.3 Register the client app (represents the consumer)

  1. App registrationsNew registration → name it (e.g. client-app) → Register
  2. Record the Application (client) ID
  3. Certificates & secretsNew client secret → record the secret value immediately (it is shown only once)

1.4 Grant the client app permission to the API

  1. client-appAPI permissionsAdd a permissionMy APIs → select the backend-app
  2. Delegated permissions → check API.AccessAdd permissions
  3. Click Grant admin consent for <tenant>

For app-only (client credentials) authorization with a claim requirement, additionally define an App role on the backend-app (member type: Applications), assign it to the client-app under Application permissions, and grant admin consent. App-only tokens carry roles, never scp. See Part 5.

Part 2 : APIM policy

2.1 Named values

Create these under APIM → Named values so the policy stays free of hard-coded IDs:

2.2 Policy XML

Apply at the API scope (APIs → <your API> → All operations → Inbound processing → policy editor):

<policies>
    <inbound>
        <base />
        <validate-azure-ad-token tenant-id="{{tenant-id}}"
                                 header-name="Authorization"
                                 failed-validation-httpcode="401"
                                 failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
            <client-application-ids>
                <!-- Only tokens acquired by this client app are accepted -->
                <application-id>{{client-app-client-id}}</application-id>
            </client-application-ids>
            <audiences>
                <audience>{{backend-app-client-id}}</audience>
            </audiences>
        </validate-azure-ad-token>
    </inbound>
    <backend><base /></backend>
    <outbound><base /></outbound>
    <on-error><base /></on-error>
</policies>

What this enforces on every request:

  • Valid signature (keys resolved automatically from the tenant)
  • Token not expired
  • Issuer belongs to the configured tenant
  • aud equals the backend-app client ID
  • The token was acquired by the pinned client app (azp / appid)

Do not add a required scp claim if consumers use the client credentials flow : app-only tokens have no scp claim and every call will fail with 401. Use roles for app-only authorization instead (Part 5).

Named value syntax: references inside policy XML use double curly braces: {{tenant-id}}. A single brace or a typo is treated as a literal string and validation fails.

Part 3 : Testing without the developer portal (script)

A test script (test-apim-oauth.sh) acquires a token, decodes and checks the claims locally, then calls the APIM endpoint and asserts the expected HTTP status.

3.1 Modes

3.2 Usage

chmod +x test-apim-oauth.sh
export TENANT_ID="<tenant-id>"
export CLIENT_APP_ID="<client-app-client-id>"
export CLIENT_SECRET="<client-app-secret>"
export BACKEND_APP_ID="<backend-app-client-id>"
export APIM_SUBSCRIPTION_KEY="<key>"     # omit if the API has subscription disabled
./test-apim-oauth.sh cc
./test-apim-oauth.sh negative

If editing defaults directly in the script, bash default-value syntax is ${VAR:-default} : the - is required. ${VAR:default} silently expands to an empty string.

3.3 What a healthy token looks like (client credentials)

{
  "aud": "<backend-app-client-id>",
  "iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",
  "azp": "<client-app-client-id>",
  "scp": null,
  "roles": null,
  "ver": "2.0"
}

Checklist:

  • aud = backend-app client ID as a bare GUID (if you see api://..., the token is v1 : revisit §1.2)
  • iss ends in /v2.0
  • azp = client-app client ID (v2 tokens use azp; v1 tokens use appid)
  • scp is null for client-credentials tokens : this is normal
  • ver = 2.0

The negative mode proves the policy rejects a genuinely valid Entra token issued for a different audience : i.e. you are enforcing audience, not just “signed by Microsoft.”

Part 4 : Developer portal OAuth 2.0 configuration

This replaces manual bearer-token pasting in the test console: the portal acquires the token via the authorization-code flow using the client-app identity, and injects it into the request automatically.

4.1 Prerequisite

The delegated scope (API.Access) must exist on the backend-app and be granted (with admin consent) to the client-app : see §§1.1 and 1.4.

4.2 Create the OAuth 2.0 server in APIM

APIM → Developer portal → OAuth 2.0 + OpenID Connect → OAuth 2.0 tab → + Add

Leave Client authentication methods and Access token sending method at their defaults.

After entering the client credentials, APIM generates a Redirect URI, e.g.:

https://<apim-name>.developer.azure-api.net/signin-oauth/code/callback/entra-oauth

Copy it exactly, then Create.

Client credentials grant in the portal : caution: if enabled, the test console does not prompt for credentials, so a token derived from the stored client secret can be exposed to any portal user. Prefer authorization code for shared portals.

4.3 Register the redirect URI on the client app

App registrations → client-app → Authentication → + Add a platform → Web → paste the redirect URI exactly (no trailing-slash differences) → Configure.

4.4 Attach the OAuth server to the API

APIs → <your API> → Settings tab → Security → OAuth 2.0 → select entra-oauthSave.

4.5 Republish the developer portal (do not skip)

Developer portal → Publish (portal editor or the Portal overview blade). The portal is a static build : OAuth configuration changes (server, scopes, API security settings) do not appear in the test console until republished. Republish after every OAuth-related change.

4.6 Test in the portal

  1. Open the developer portal in a private/incognito window and sign in
  2. Navigate to the API → choose an operation → Try it
  3. In the Authorization section, select the entra-oauth server → Authorization code
  4. Complete the Entra sign-in popup (first run shows a consent prompt for API.Access)
  5. The console auto-populates Authorization: Bearer eyJ...
  6. (Optional but recommended) decode the token at https://jwt.ms and verify: aud = backend-app client ID, azp = client-app client ID, ver = 2.0; scp = API.Access and user claims (name, oid) present : this is a delegated token, unlike the app-only script token
  7. Provide the subscription key if prompted → Send → expect 200

The gateway policy accepts both token shapes (delegated and app-only) because it imposes no scp/roles requirement.

Part 5 : Optional hardening: app roles for app-only authorization

The base policy authenticates the SPN but doesn’t check what it is allowed to do. To add authorization for app-only (client credentials) consumers:

  1. backend-app → App roles → Create app role: display name API Invoker · value API.Invoke · allowed member types Applications
  2. client-app → API permissions → Add a permission → My APIs → backend-app → Application permissions → check API.InvokeGrant admin consent
  3. Add to the policy:
<required-claims>
    <claim name="roles" match="all">
        <value>API.Invoke</value>
    </claim>
</required-claims>

App-only tokens will then carry "roles": ["API.Invoke"].

Mixed consumers: delegated tokens (developer portal) carry scp but not roles; app-only tokens carry roles but not scp. A single policy requiring one of them will reject the other consumer type. Options: keep claim requirements out of the shared scope and enforce per-consumer (e.g. per product), or use choose/when logic to branch on token shape.

Troubleshooting reference

General debugging workflow

  1. Decode the token (jwt.ms or the script’s built-in decoder)
  2. Compare aud, iss, azp/appid, scp/roles, ver against the policy and named values
  3. If claims look right, run a traced request from the portal Test tab : it identifies the failing element in one line

Security notes

  • Rotate any client secrets and subscription keys that were used during POC testing (pasted into terminals, scripts, or chats)
  • Prefer Key Vault-backed named values for secrets in APIM
  • Limit the OAuth server’s default scope to the minimum needed for portal testing
  • Avoid enabling the client credentials grant on the portal’s OAuth server for shared portals (token exposure risk)
  • Client secrets expire : set a calendar reminder or use certificate credentials / managed identities for production consumers

Reference documentation


메타데이터
post_id
a378c00fa783
slug
azure-api-management-oauth-2-0-with-microsoft-entra-id-a378c00fa783
url
https://medium.com/@ukhan262/azure-api-management-oauth-2-0-with-microsoft-entra-id-a378c00fa783
canonical_url
https://medium.com/@ukhan262/azure-api-management-oauth-2-0-with-microsoft-entra-id-a378c00fa783
author_url
https://medium.com/@ukhan262
status
ok
fetched_at
2026-07-13 06:23:13