Authentication & Authorization at the Gateway
Why this matters
A subscription key answers "which consumer is this?" but not "is this a real, authenticated user/app allowed to do this?" For that you validate a token — and doing it at the gateway means every backend behind APIM is protected without each one reimplementing auth. This is where the identity work from track 16 pays off directly: the same Entra ID token you learned to reason about (issuer, audience, scopes, signature) gets validated by a single APIM policy before any request touches your Container App. Get the audience/issuer right and the gateway is your auth chokepoint; get them wrong and you either reject valid callers or wave through invalid ones.
Concepts
Two different questions: identification vs. authentication
Keep these apart. A subscription key identifies a consumer for metering and throttling — it's a shared secret tied to a subscription, not proof of a user's identity, and it's fine for "which plan is this call on." Token authentication (OAuth2/JWT) proves the caller is a specific authenticated user or application, with scopes/roles describing what they're allowed to do. Mature APIs use both: the key for the product/metering layer, the token for identity/authorization. A common design is subscription key optional-or-off for first-party callers who present a strong Entra ID token, and required for partners on a metered plan. Decide per API which layers apply.
Anatomy of the token you're validating (recap from track 16)
The token is a JWT — three base64url parts (header.payload.signature). The
parts that matter to a gateway policy:
iss(issuer) — who minted the token, e.g.https://login.microsoftonline.com/<tenant-id>/v2.0. Must match the tenant you trust.aud(audience) — who the token is for: your API's app ID URI or client id. This is the field people get wrong most often — a token minted for a different audience is a valid token but not for you.exp/nbf— expiry / not-before; the policy rejects expired or not-yet-valid tokens automatically.- scopes (
scp) / roles (roles) — what the caller may do; you assert required ones in the policy. - signature — signed with Entra's keys; APIM fetches the tenant's public keys from the OpenID configuration / JWKS endpoint to verify it. This is the same token shape and the same Entra ID issuer you learned to dissect in track 16 — the gateway just checks it mechanically on every request.
The validate-jwt policy
validate-jwt is an inbound policy (module 03's pipeline) that verifies the
token before the backend is called. You give it: where to find the token
(Authorization: Bearer header, by default), the OpenID metadata URL (so it
can fetch signing keys and the issuer), the audiences you accept, the
issuers you trust, and optionally required claims (scopes/roles). If any
check fails, the policy short-circuits with 401 (missing/invalid token) or
403 (valid token, insufficient claims) — and the request never reaches your
Container App. Because it runs at the gateway, your backend can trust that
anything it receives already passed token validation (defense in depth still
applies, but the edge is covered).
The two failure modes: 401 vs. 403, and the audience/issuer trap
Distinguish crisply:
- 401 Unauthorized — no token, malformed token, bad signature, expired, or audience/issuer mismatch. The token isn't acceptable at all for this API.
- 403 Forbidden — token is valid and for you, but it lacks a required scope/role you asserted. Authenticated but not authorized.
The single most common real bug: you configure <audience> with the wrong value
(e.g. the client id instead of the app ID URI, or a v1 vs v2 audience
mismatch), and every valid token is rejected with 401 even though nothing is
actually wrong with the tokens. You'll reproduce and fix exactly this — it's the
gateway analogue of track 16's "the token is fine, the relying party config is
wrong."
Client certificate authentication (mutual TLS)
For machine-to-machine callers where you'd rather not manage tokens, APIM
supports client certificate (mTLS) auth: the caller presents a client cert
during the TLS handshake, and a validate-client-certificate policy (or context
checks on context.Request.Certificate) verifies its thumbprint, issuer, or
subject against values you trust. This is the same "prove identity with a
certificate rather than a bearer secret" idea behind mesh mTLS (track 13), but
here it's north-south at the product edge rather than east-west between
services. It's common for partner integrations and is a survey-level topic here —
know it exists, know when you'd reach for it (long-lived B2B callers, no user
context), and know it's configured at the gateway, not the backend.
Command reference
Auth is mostly policy XML plus an Entra ID app registration. The registration
uses az ad (from track 16); the validation uses a validate-jwt inbound policy.
| Command | What it does | Example |
|---|---|---|
az ad app create |
Register an Entra ID app (the API's identity/audience) | az ad app create --display-name "orders-api" |
az account get-access-token |
Get a real Entra ID token to test with | az account get-access-token --resource api://<app-id> --query accessToken -o tsv |
az apim api policy create |
Apply the validate-jwt policy to the API |
az apim api policy create --resource-group rg --service-name <inst> --api-id orders --policy-format xml --value "@jwt.xml" |
az apim api update (--subscription-required) |
Turn the subscription-key requirement on/off | az apim api update --resource-group rg --service-name <inst> --api-id orders --subscription-required false |
validate-jwt policy explained:
<inbound>
<base />
<validate-jwt header-name="Authorization" failed-validation-httpcode="401"
failed-validation-error-message="Invalid or missing token">
<openid-config url="https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>api://<app-id></audience>
</audiences>
<issuers>
<issuer>https://login.microsoftonline.com/<tenant-id>/v2.0</issuer>
</issuers>
<required-claims>
<claim name="scp" match="any">
<value>Orders.Read</value>
</claim>
</required-claims>
</validate-jwt>
</inbound>
header-name="Authorization"— where the bearer token is read from (default).<openid-config url="...">— the tenant's OpenID metadata; APIM uses it to fetch signing keys (JWKS) and validate the signature and issuer. Using this is better than hardcoding keys, which rotate.<audiences><audience>— theaudvalue(s) you accept; must match what the token was minted for. The #1 misconfiguration.<issuers><issuer>— the trustediss; the Entra tenant's v2 issuer URL.<required-claims>with<claim name="scp" match="any">— assert the caller has at least one of the listed scopes; failing this yields 403, not 401.failed-validation-httpcode="401"— the status returned when validation fails.
az account get-access-token --resource api://<app-id> --query accessToken -o tsv
--resource api://<app-id>— request a token whose audience is your API's app ID URI. Point this at the wrong resource and you'll get a token that fails the audience check — exactly the diagnose-and-fix below.
Hands-on exercises
Time note: Consumption tier is fine and fast. The slow part here is Entra ID propagation (app registrations and token audiences can take a minute). If track 16 isn't built yet, the
az ad app create/az account get-access-tokensteps below stand alone — you don't need track 16's modules to run them.
Instance + backend + API. Same base as before: Consumption APIM, a Container App
orders, anordersAPI with aGET /operation,$key,$gw.Register an Entra ID app to represent the API.
$app = az ad app create --display-name "orders-api-m04" --query appId -o tsv $tenant = az account show --query tenantId -o tsv # set an Application ID URI so tokens can target it as audience: az ad app update --id $app --identifier-uris "api://$app"Record
$app(client/app id) and$tenant. The audience will beapi://$app.Get a real token for the right audience.
$token = az account get-access-token --resource "api://$app" --query accessToken -o tsvPaste the token into https://jwt.ms (or decode locally) and read the
audandissclaims — confirmaudisapi://$appandissis the v2 issuer for$tenant. This is the track-16 token you're about to validate.Apply a
validate-jwtpolicy. Savejwt.xmlusing the template from the command reference, substituting$tenantand$app(drop the<required-claims>block for now to isolate audience/issuer):az apim api policy create --resource-group rg-apim-m04 --service-name $apim --api-id orders ` --policy-format xml --value "@jwt.xml"Call with a valid token → 200.
curl -H "Ocp-Apim-Subscription-Key: $key" -H "Authorization: Bearer $token" "$gw/orders/"Expect 200 — the gateway validated the signature, issuer, audience, and expiry before forwarding.
Call with no token → 401.
curl -i -H "Ocp-Apim-Subscription-Key: $key" "$gw/orders/"Expect 401 with your
failed-validation-error-message. The subscription key alone is no longer enough — identity is now required.Require a scope and observe 403 vs 401. Add the
<required-claims>block asserting a scope the token doesn't have, re-apply, and call with the valid token. Expect 403 (authenticated but missing the required scope) — a different failure from the 401 of no token. Remove the claim to restore 200.Diagnose and fix: valid token rejected due to wrong audience. This is the canonical bug. Edit
jwt.xmlso<audience>is a wrong value (e.g.api://00000000-0000-0000-0000-000000000000), re-apply, and call with your still-valid token:curl -i -H "Ocp-Apim-Subscription-Key: $key" -H "Authorization: Bearer $token" "$gw/orders/"You get 401 Invalid token even though the token is genuinely valid. Diagnose: decode the token (jwt.ms), read its real
aud, and compare to the<audience>in the policy — they don't match. Fix: set<audience>back toapi://$app, re-apply, and confirm 200 returns. Lesson: 401 on a token you know is valid almost always means an audience or issuer mismatch in the policy, not a bad token — the exact gateway version of track 16's relying-party-config bug.Bonus — wrong-resource token. Request a token for a different resource (
--resource https://graph.microsoft.com) and call with the correct policy. Confirm 401: the token is real but itsaudis Graph, not your API. This shows the failure from the token side rather than the policy side.Cleanup.
az group delete --name rg-apim-m04 --yes --no-wait az ad app delete --id $app(Delete the app registration too — it's free but leaving orphaned app regs around is untidy and a minor security-hygiene issue from track 16.)
Independent challenge
Protect an API with both layers: a subscription key and a validate-jwt
policy that accepts only Entra ID tokens for your API's audience and issuer, and
requires a specific scope. Prove all four outcomes with real curl calls: (a)
valid key + valid token + right scope → 200; (b) valid key, no token → 401; (c)
valid key + valid token missing the scope → 403; (d) valid key + a token minted
for the wrong audience → 401. Then, drawing on track 16's token model,
write a short note explaining for each 401/403 which specific claim or check
failed and where you'd look first. Finish by reproducing the wrong-audience bug
once more and fixing it from memory. Delete the resource group and the app
registration afterward.
Stuck? One hint
Decode every token at jwt.ms before you blame the policy. Line up three things:
the token's aud/iss/scp claims, the <audiences>/<issuers>/
<required-claims> in your policy, and the HTTP status you got. A 401 means
aud/iss/signature/expiry didn't line up (usually audience); a 403 means
they did but a required scope was absent. The bug is almost never a "bad
token" — it's a mismatch between what the token says and what the policy demands.
Common mistakes & troubleshooting
- Audience mismatch → 401 on valid tokens. The most common APIM auth bug.
The
<audience>in the policy must equal the token'saud. Decode the token and compare; don't guess. - Issuer version mismatch (
v1vsv2). Entra can issue v1 (sts.windows.net/<tenant>/) or v2 (login.microsoftonline.com/<tenant>/v2.0) issuers/audiences depending on the app config. Match the policy to the token you actually receive. - Confusing 401 and 403. 401 = token unacceptable (missing/invalid/wrong audience/expired). 403 = valid token, missing required scope/role. They point at different fixes.
- Relying on the subscription key for identity. The key identifies a plan, not a user/app. Use a token for real authentication; keep the key for metering/throttling.
- Hardcoding signing keys. Use
<openid-config>so APIM fetches current JWKS; Entra rotates keys and a hardcoded key breaks silently later. - Forgetting Entra propagation. A new app registration or identifier URI can take a minute to be usable; a token request right after creating the app may fail transiently. Wait and retry.
- Cost pitfall. App registrations and
validate-jwtare free, but this is a place people leave a Developer-tier instance running while fiddling with Entra config over hours. If you're on a classic tier for any reason, watch the clock and delete same-day; prefer Consumption, which idles free.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What does a subscription key prove vs. what does a JWT prove? Why use both?
- Which JWT claim is the audience, and why is it the most common source of a valid-token-rejected bug?
- In
validate-jwt, what is<openid-config>for, and why is it better than listing signing keys? - Distinguish the conditions that yield 401 vs. 403 from
validate-jwt. - You have a token you know is valid but APIM returns 401. What's the first thing to check?
- Where in the policy pipeline does
validate-jwtrun, and what does that mean for the backend? - When would you reach for client-certificate auth instead of JWT, and which prior track is it conceptually related to?
- How do you get a real Entra ID token targeting your API's audience from the CLI, and how do you inspect its claims?
Show answers
- A subscription key proves which consumer/plan is calling (metering/ throttling); a JWT proves the authenticated identity of a user/app and, via scopes/roles, what they may do. Use both to separate the product/metering layer from the identity/authorization layer.
aud. A token minted for a different audience is otherwise valid but not for your API; if the policy's<audience>doesn't match the token'saud, every valid token is rejected with 401.- It points at the tenant's OpenID metadata so APIM fetches current signing keys (JWKS) and the issuer. Better than hardcoded keys because Entra rotates keys — hardcoded ones break silently.
- 401: missing/malformed/expired token, bad signature, or audience/issuer mismatch. 403: valid token but missing a required scope/role.
- The audience (and issuer) in the policy vs. the token's actual
aud/iss— decode the token (jwt.ms) and compare. It's almost always a policy/config mismatch, not a bad token. - In
<inbound>, before the backend call. The backend can therefore trust that anything it receives already passed token validation at the edge. - For machine-to-machine/partner callers with no user context where you'd rather present a certificate than manage bearer tokens. Conceptually related to track 13's mesh mTLS (proving identity with a cert), but north-south at the product edge.
az account get-access-token --resource api://<app-id> --query accessToken -o tsv; inspect it by decoding at jwt.ms (or any JWT decoder) and readingaud,iss,scp,exp.
Next
05-backend-integration-patterns — stop pointing at public FQDNs: reach a private Container App over VNet integration, define named backends, load-balance across multiple backends, and meet backend pools and circuit breakers (the gateway cousin of track 13's mesh resilience).