Azure Integration

APIM Policies From Scratch: Rate-Limit-By-Key, JWT Validation and Request/Response Transformation

You stood up an Azure API Management (APIM) instance, imported an OpenAPI spec, and got a working /products endpoint behind a managed gateway. It works — and it is also wide open. Anyone with the URL can hammer it as fast as they like, there is no check on who is calling, and the backend’s raw response (internal field names, a Server: Kestrel header, a 500 stack trace) flows straight back to the caller. The gateway is doing the one thing a gateway exists to not do: passing traffic through untouched. The piece that turns APIM from a reverse proxy into an actual API gateway is policies — small XML statements that run on every request and response and let you throttle, authenticate, rewrite, cache, and mock without touching backend code.

This article is a build guide for the three policies you will reach for on day one and use forever: rate-limit-by-key (throttle callers so one client cannot starve the rest or run up your backend bill), validate-jwt (reject requests that do not carry a valid OAuth2 / OpenID Connect token from your identity provider, before they touch the backend), and request/response transformation (set-header, rewrite-uri, set-body, find-and-replace — reshape what goes in and what comes back). You will learn the four-section policy pipeline (inboundbackendoutboundon-error) that every policy lives inside, the scope hierarchy (Global → Product → API → Operation) that decides which policies apply where, and the exact az, portal and Bicep moves to ship each one.

By the end you will not just have copied an XML snippet — you will understand why a rate-limit-by-key counter is per-gateway-region, why validate-jwt needs the OpenID configuration URL and not just a key, and why set-header with exists-action="override" behaves differently from "append". The centrepiece is a hands-on lab that builds all three policies on a real APIM instance using the free Consumption or Developer tier, validates each one with curl, and tears it all down. Everything is concrete: real policy XML, real az apim commands, real error codes (401, 429, the Retry-After header), and the limits that matter.

What problem this solves

Without policies, your gateway is a thin pass-through, and three categories of pain show up in production within weeks.

No throttling means one caller can ruin the day for everyone. A buggy client in a retry loop, a partner who “tests” with a load generator, or a scraper can drive your request volume from 50/second to 5,000/second. Your backend — an App Service plan, a database, a downstream paid API charging per call — has no idea this is abuse; it just sees load and either falls over (503s for every user) or runs up a bill. The gateway is the only place you can see “all calls from this subscription key / this IP / this user” and say no further. Rate limiting is not a nice-to-have; it is the blast-radius control for your whole API surface.

No token validation means your backend trusts the network. If APIM forwards every request to the backend regardless of whether it carries a valid token, then the backend must authenticate every call — duplicating identity logic in every service, and leaving a window where an unauthenticated request reaches your code before being rejected. Worse, teams often “solve” this by making the backend network-private and assuming anything that reached it is trusted — which is exactly the flat-trust model that lets one compromised component pivot everywhere. Validating the JWT at the gateway means a request without a valid, correctly-scoped token is rejected with 401 at the edge and never costs you a backend invocation.

No transformation means backend internals leak and clients break on every change. The shape your backend returns is rarely the shape you want to expose: it has internal field names, debug headers, a different version of a URL path, an HTTP error body that exposes your stack. And when the backend changes — a renamed field, a moved route — every client breaks unless the gateway absorbs the difference. Transformation policies let the gateway present a stable, clean contract to callers while the backend evolves underneath. Who hits all three of these? Anyone running a public or partner-facing API, anyone fronting a legacy backend they cannot freely change, and anyone whose API costs money to serve.

Here is the field at a glance — the three policy jobs, what breaks without each, and the single policy that fixes it:

Policy job What breaks without it The policy Scope you usually apply it at
Throttle callers One client starves the rest; runaway backend cost rate-limit-by-key Product (per plan) or API
Authenticate the caller Backend trusts the network; unauth requests reach code validate-jwt Global or API (all operations)
Reshape the request Backend route/version churn breaks clients rewrite-uri, set-header, set-body API or Operation
Clean the response Internal headers/fields and error stacks leak out set-header (delete), find-and-replace Global (strip headers) + API
Cap quota over time A caller drains a daily/monthly allowance unevenly quota-by-key (sibling of rate-limit) Product

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already have an APIM instance (any tier — Consumption, Developer, Basic, Standard, or Premium) and at least one API imported, plus a backend it routes to (an App Service, an Azure Function, or even a public sample API). You should be comfortable running az in Cloud Shell, reading XML, and you should understand at a high level what a JWT is (a signed token carrying claims like aud, iss, exp, scp/roles). Familiarity with OAuth2 / OIDC helps for the JWT section — if you want the deeper identity grounding, the OIDC and OAuth2 on Entra ID: Choosing the Right Flow (Auth Code, PKCE, Client Credentials) article covers how the token is issued in the first place, and App Registrations vs Enterprise Applications: The Service Principal Model Explained explains the app registration that defines your API’s audience.

This sits in the API & integration layer of an Azure architecture, in front of your compute. It pairs naturally with the compute-choice decision in Azure App Service vs Container Apps vs AKS: Choose the Right Compute (APIM fronts any of them), with Azure Key Vault: Secrets, Keys and Certificates Done Right for storing backend secrets as named values, and with Azure Monitor and Application Insights: Full-Stack Observability because APIM emits its policy decisions (a 429, a validate-jwt rejection) as telemetry you will want to see. A quick map of where each concern lives:

Layer What lives here Owns which policy concern
Caller (client app) Acquires a token, sends the subscription key Must send valid JWT + Ocp-Apim-Subscription-Key
APIM gateway Runs the policy pipeline Rate limit, JWT validation, transformation
Named values / Key Vault Secrets, backend URLs, signing config Backend credentials referenced by policies
Identity provider (Entra ID) Issues and signs the JWT Publishes OIDC metadata validate-jwt reads
Backend (App Service / Function) Your actual business logic Should trust only gateway-validated traffic

Core concepts

Four mental models make every later step obvious.

A policy is code that runs on the request path, expressed as XML. Each scope has a single policy document — an XML file with up to four sections. You do not write one policy per rule; you write a document whose sections contain an ordered list of policy statements (<rate-limit-by-key />, <validate-jwt>…</validate-jwt>, <set-header>…</set-header>). The gateway executes them top to bottom. Policies are evaluated per request, in the gateway, before (and after) your backend is ever called — which is the whole reason a rejected request costs you nothing downstream.

The pipeline has four sections and they run in a fixed order. inbound runs on the way in (this is where you throttle, authenticate, and reshape the request). backend wraps the call to your backend (retries, timeouts, the actual forward-request). outbound runs on the way back (reshape the response, strip headers). on-error runs only if any section throws. Putting a policy in the wrong section is the single most common authoring bug: a set-header meant to clean the response does nothing if you place it in inbound. The order is always inbound → backend → outbound, with on-error as an exception handler around the lot.

Scope is a four-level hierarchy and parents wrap children. A policy document can exist at Global (all APIs in the instance), Product (all APIs in a product/plan), API (all operations of one API), and Operation (a single method+path). At runtime APIM composes them: the effective inbound is Global-inbound, then Product, then API, then Operation. The magic word is <base /> — it marks where the parent scope’s policy is injected. Put <base /> first in your inbound and the parent runs before you; put it last and you run before the parent. Leave it out and the parent scope is skipped entirely for that section. This is how you decide, for example, that a global “strip Server header” rule always runs, or that an API overrides the product’s rate limit.

Rate-limit counters are scoped to the gateway, not to your whole tenant. This trips everyone. A rate-limit-by-key counter is maintained per gateway region (and historically per gateway unit in some configurations). If your APIM is deployed to two regions, a limit of “100 calls / 60 s” can admit up to ~200 across both, because each region counts independently. On the self-hosted gateway the counter is per-replica unless you wire up an external cache. None of this is a bug — it is the cost of a low-latency, distributed counter — but you must size the limit knowing it is per region, and reach for quota-by-key (longer windows) or an external Redis cache when you need a tighter shared count. The mental model: rate limit = “smooth out bursts, cheap and local”; quota = “enforce a total allowance over a long window.”

The vocabulary in one table

Term One-line definition Where it lives Why it matters
Policy document The XML for one scope Per Global/Product/API/Operation The unit you edit
Section inbound/backend/outbound/on-error Inside a policy document Decides when a statement runs
<base /> Injects the parent scope’s section Inside a section Decides parent-vs-child order
Scope Global / Product / API / Operation The four levels Decides where a policy applies
rate-limit-by-key Throttle by an expression (key/IP/claim) inbound Per-region counter; returns 429
quota-by-key Total-allowance cap over a long window inbound Renewable quota; returns 403
validate-jwt Verify a bearer token’s signature/claims inbound Returns 401 on failure
set-header Add/override/delete a header inbound/outbound Reshape request or response
rewrite-uri Change the path sent to the backend inbound Decouple public path from backend
set-body Replace the body with an expression inbound/outbound Reshape payloads
Named value A reusable key/value (can be secret/KV) Instance-level Reference secrets in policies
Policy expression C#-ish @(...) snippet Inside attributes/elements Dynamic values from context

Concept 1 — The policy pipeline: the four sections in order

Every policy document is the same shape. Memorise it; you will type it hundreds of times:

<policies>
  <inbound>
    <base />
    <!-- runs on the way IN: throttle, authenticate, rewrite request -->
  </inbound>
  <backend>
    <base />
    <!-- wraps the backend call: forward-request, retry, timeout -->
  </backend>
  <outbound>
    <base />
    <!-- runs on the way OUT: reshape response, strip headers -->
  </outbound>
  <on-error>
    <base />
    <!-- runs ONLY if something above threw -->
  </on-error>
</policies>

The execution order for a successful request is inbound (top→bottom) → backendoutbound (top→bottom). If anything throws — a validate-jwt rejection, a backend timeout, an explicit <return-response> — control jumps to on-error, and the outbound section is skipped unless you deliberately resume it. That asymmetry matters: response-cleaning policies you put in outbound do not run on an error path, so if you must (for example) strip an internal header even on errors, you also handle it in on-error.

Here is what belongs in each section and what does not:

Section Runs when Put here Never put here
inbound On every request, first rate-limit-by-key, validate-jwt, rewrite-uri, request set-header/set-body, check-header Response-only logic
backend Around the backend call forward-request (implicit), retry, set-backend-service, timeouts Caller authentication
outbound On a successful response Response set-header, find-and-replace, set-body, json-to-xml Throttling (too late)
on-error Only if a section threw Shape the error, log via trace, set a clean error body Anything that itself can throw

A subtle but critical rule about <base />: it is the seam where the parent scope’s matching section is spliced in. Get its position right and you control composition precisely:

<base /> placement Effect Use it when
First in the section Parent runs before your statements You extend the parent (e.g. add to a global baseline)
Last in the section Your statements run before the parent You must act first (e.g. set context the parent reads)
Omitted Parent section is skipped for that scope You fully override the parent (rare; be deliberate)
Present but parent empty No-op Harmless; keep it for forward-compatibility

The portal’s policy editor shows the computed policy (with <base /> expanded inline) when you choose “Calculated effective policy” — use that view to confirm what actually runs, because reasoning about four nested documents in your head is where mistakes hide.

Concept 2 — Scope: Global, Product, API, Operation

The four scopes nest like Russian dolls, and a request can be subject to all four at once. Knowing where to place a policy is half the skill:

Scope Applies to Edit it via Typical policies here
Global (“All APIs”) Every API in the instance Portal → APIs → All APIs → Policies Strip Server/X-Powered-By, global CORS, baseline logging
Product Every API added to that product Portal → Products → Product → Policies rate-limit-by-key, quota-by-key (the plan’s limits)
API Every operation of one API Portal → APIs → API → Design → Policies (API level) validate-jwt, API-wide rewrite-uri/headers
Operation One method+path Portal → APIs → APIOperation → Policies Operation-specific transforms, mock for one route

The composition order at runtime is Global → Product → API → Operation for inbound, and the reverse-friendly nesting means each child’s <base /> pulls in its parent. A few placement rules that come from experience, not docs:

Concept 3 — rate-limit-by-key: throttling callers

rate-limit-by-key lets N calls through per time window, keyed on an expression you choose, and returns HTTP 429 Too Many Requests (with a Retry-After header) once the window is exhausted. The “by-key” part is the power: you decide what identity to count against. The minimal form, keyed on the subscription key:

<rate-limit-by-key
    calls="100"
    renewal-period="60"
    counter-key="@(context.Subscription?.Key ?? "anonymous")" />

That admits 100 calls per 60 seconds per subscription key. The counter-key is a policy expression; swap it to change what you throttle:

counter-key expression Throttles per… Use when
@(context.Subscription.Id) Subscription The standard “per API key” limit
@(context.Request.IpAddress) Client IP Anonymous/public APIs with no key
@(context.Request.Headers.GetValueOrDefault("X-User-Id","")) A header value A header your client sets per end-user
@(context.User.Id) Authenticated user Per-user fairness on an authenticated API
@((string)context.Variables["jwt-sub"]) A JWT claim Per-token-subject after validate-jwt

The key attributes and their real limits:

Attribute Meaning Values / limit Gotcha
calls Calls allowed per window Positive integer Counter is per gateway region — multi-region multiplies it
renewal-period Window length in seconds 1 – 300 (max 5 minutes) For longer windows use quota-by-key (up to a renewal of a full period)
counter-key What to count against Any policy expression If it evaluates to the same value for everyone, you throttle the whole API
increment-condition Only count when true Boolean expression E.g. count only 5xx responses to limit retries
increment-count How much each call adds Integer (default 1) Weight expensive operations heavier
retry-after-header-name Custom Retry-After header Header name Default emits Retry-After in seconds
remaining-calls-header-name Surface remaining budget Header name Great DX — clients self-throttle

The two policies are siblings but solve different problems — choose deliberately:

Aspect rate-limit-by-key quota-by-key
Purpose Smooth bursts (rate) Cap a total allowance (volume)
Window Seconds (≤ 300) Long (e.g. per day/week/month via renewal-period seconds)
On exceed 429 Too Many Requests 403 Forbidden (“quota exceeded”)
Counter scope Per gateway region Per gateway region
Typical use “≤ 10 req/s per key” “≤ 1,000,000 calls/month per key”
Headers Retry-After, remaining-calls remaining-calls, remaining-quota

The non-obvious production reality, stated plainly: the counter is per region, and on the Consumption and self-hosted gateways it can be even less global than that. If you run APIM in two regions behind Front Door, a “100/60s” limit can admit ~200/60s globally. For a hard shared ceiling, either size per region, use quota-by-key (whose drift matters less over a long window), or register an external Redis cache on a self-hosted gateway so counters are shared across replicas. Do not assume the limit is a global truth.

<!-- A richer rate limit: per subscription, surfacing budget to the client -->
<rate-limit-by-key
    calls="600"
    renewal-period="60"
    counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
    remaining-calls-header-name="X-RateLimit-Remaining"
    retry-after-header-name="Retry-After" />

Concept 4 — validate-jwt: authenticating the caller

validate-jwt checks that the request carries a valid bearer token and rejects it with HTTP 401 Unauthorized if not. “Valid” means: the signature verifies against the issuer’s published signing keys, the token is not expired (exp/nbf), and the claims you require — aud (audience), iss (issuer), and any custom required-claims like a scope or role — match. Crucially, validate-jwt fetches the issuer’s signing keys automatically from the OpenID configuration URL; you do not paste a key, you point at metadata and APIM keeps keys fresh through rotation.

The canonical form against Microsoft Entra ID:

<validate-jwt header-name="Authorization" failed-validation-httpcode="401"
              failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
  <openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
  <audiences>
    <audience>api://your-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>Products.Read</value>
    </claim>
  </required-claims>
</validate-jwt>

The configuration knobs and what each one guards against:

Element / attribute Purpose Get it wrong →
header-name Which header holds the token Default Authorization; token in a different header → 401
openid-config url Where to fetch signing keys + metadata Wrong/unreachable URL → every request 401 (no keys)
<audiences> Who the token is for (your API’s app-ID URI) Mismatch (e.g. used the client ID) → 401, audience invalid
<issuers> Who issued the token (your tenant authority) v1 vs v2 issuer mismatch is a classic 401
required-claims Scopes/roles the token must carry Missing scope → 401 even with a valid token
match="any"|"all" Whether one or all claim values must match all is stricter; default is all
failed-validation-httpcode Status returned on failure Default 401; some set 403 for “valid token, wrong scope”
require-expiration-time Demand an exp claim Default true; do not disable in prod
require-signed-tokens Reject unsigned tokens Default true; never disable
clock-skew Allowed time drift Default ~5 min; widen only if clocks genuinely differ

The single most common validate-jwt failure is an audience mismatch, worth burning into memory: the aud claim in an Entra-issued access token is the Application ID URI of the API being called (e.g. api://<api-app-id>), not the client app’s ID and not Microsoft Graph’s. People acquire a token for the wrong resource and get a 401 that says nothing useful. Decode the token at jwt.ms and compare its aud to your <audience> — they must be identical strings.

A second classic is the v1/v2 issuer split: the v1 endpoint issues tokens with issuer https://sts.windows.net/{tenant}/, the v2 endpoint https://login.microsoftonline.com/{tenant}/v2.0. If your <issuer> says v2 but the token came from a v1 flow, validation fails — match the issuer to the endpoint your clients actually use.

You can validate tokens from any OIDC provider the same way — Auth0, Okta, a custom IdP — by pointing openid-config at their .well-known/openid-configuration. The mechanism is standard; only the URLs and claim names change.

Concept 5 — Request and response transformation

Transformation policies reshape what flows through the gateway. The four you use constantly:

Policy Section(s) What it does Example
set-header inbound/outbound Add, override, or delete a header Inject X-Forwarded-Host; delete Server
rewrite-uri inbound Change the path sent to the backend Public /v2/products → backend /api/products
set-body inbound/outbound Replace the body via an expression Wrap/unwrap a payload, redact a field
find-and-replace inbound/outbound String replace in the body Rewrite a hostname in returned URLs

set-header is the workhorse, and its exists-action attribute is where people slip:

exists-action If the header already exists… Use for
override Replace its value Forcing a value (Content-Type, an auth header)
append Add another value Multi-value headers (e.g. Set-Cookie)
skip Leave the existing value “Set a default only if absent”
delete Remove the header Stripping Server, X-Powered-By (no <value> needed)

Examples that show the patterns. Strip server internals on the way out (do this Globally):

<outbound>
  <base />
  <set-header name="Server" exists-action="delete" />
  <set-header name="X-Powered-By" exists-action="delete" />
  <set-header name="X-AspNet-Version" exists-action="delete" />
</outbound>

Inject a header the backend needs (on the way in):

<inbound>
  <base />
  <set-header name="X-Forwarded-Host" exists-action="override">
    <value>@(context.Request.OriginalUrl.Host)</value>
  </set-header>
</inbound>

Rewrite the public path to the backend path:

<inbound>
  <base />
  <rewrite-uri template="/api/products/{id}" />
</inbound>

Rewrite a backend hostname that leaks into response URLs (find-and-replace over the body):

<outbound>
  <base />
  <find-and-replace from="https://internal-backend.azurewebsites.net" to="https://api.contoso.com" />
</outbound>

The two rules that prevent most transformation bugs: request transforms go in inbound, response transforms go in outbound (a response set-header in inbound silently does nothing), and set-body buffers the whole body — fine for JSON APIs, but avoid it on large streaming/binary payloads where buffering hurts latency and memory.

Architecture at a glance

The diagram traces a single request left to right through the policy pipeline so you can see where each policy bites. A client sends an HTTPS request carrying two things: a bearer JWT in the Authorization header and the Ocp-Apim-Subscription-Key that identifies its subscription. The request lands on the APIM gateway, where the inbound section runs in order: first validate-jwt verifies the token’s signature and claims against the OIDC metadata it pulls from Microsoft Entra ID (failure short-circuits the whole pipeline with a 401); then rate-limit-by-key increments the per-region counter for this caller (over the cap → 429 with Retry-After); then transformation policies (rewrite-uri, set-header) reshape the request. Only a request that survives all three reaches the backend (an App Service, Function, or container) via the backend section. On the way back, the outbound section strips internal headers and rewrites any leaked hostnames before the clean response returns to the client.

Read the numbered badges as the four failure points you will actually hit: a rejected token (1), an exhausted rate limit (2), a wrong backend route after rewrite-uri (3), and a leaked internal header that outbound should have stripped (4). Notice that policies run as a strict pipeline — a failure at an earlier policy means later ones never execute, which is exactly why ordering (validate-jwt before rate-limit-by-key, both before transforms) is a deliberate choice, not an accident. The whole method is: authenticate first, throttle second, reshape third, call the backend, then clean the response.

Azure API Management policy pipeline traced left to right for one request: a client sends an HTTPS request with a JWT bearer token and an Ocp-Apim-Subscription-Key to the APIM gateway, where the inbound section runs validate-jwt (verifying signature and claims against Microsoft Entra ID OIDC metadata, returning 401 on failure), then rate-limit-by-key (incrementing a per-region counter, returning 429 with Retry-After when exceeded), then transformation policies rewrite-uri and set-header; surviving requests reach the App Service or Function backend via the backend section; the outbound section strips internal Server and X-Powered-By headers and rewrites leaked hostnames before the clean response returns — with numbered badges marking the four failure points: rejected token, exhausted rate limit, wrong backend route, and leaked internal header

Real-world scenario

Mintleaf Logistics exposes a partner shipping API: GET /v2/shipments/{id} and POST /v2/shipments, fronting a .NET App Service backend on an S1 plan in Central India. They run APIM Standard in a single region, fronted by Front Door. They have two partner tiers as APIM products: Bronze (free, low volume) and Gold (paid, high volume). The platform team is three engineers; APIM costs about ₹48,000/month on the Standard tier.

The trouble started with a single Bronze partner whose integration had a retry bug: on any 5xx it retried immediately with no backoff, and during a backend deploy it generated 40,000 requests in ninety seconds, saturating the S1 backend and causing 503s for every partner, Gold included. The on-call engineer’s first instinct was to scale the backend up — which helped for ten minutes until the retry storm resumed. The real problem was that the gateway was throttling nothing: there was a rate-limit-by-key on the Gold product but none on Bronze, and the misbehaving partner was on Bronze.

The breakthrough was realising APIM, not the backend, was the right control point. They added rate-limit-by-key calls="60" renewal-period="60" keyed on the subscription at the Bronze product scope, and quota-by-key for a daily cap. The retry storm now hit a 429 at the gateway in under a millisecond, never touching the backend. But they also noticed something during the incident review: the backend’s 503 error body was leaking the internal hostname (mintleaf-ship-prod.azurewebsites.net) and a Server: Kestrel header straight to partners — minor, but exactly the kind of internal detail that should never cross the gateway. A Global outbound policy now deletes Server/X-Powered-By and a find-and-replace rewrites the internal hostname to api.mintleaf.com.

The third fix closed a quieter gap. Both operations were “protected” only by the subscription key — which is an identification mechanism, not authentication; a leaked key was full access. They added validate-jwt at the API scope, requiring an Entra-issued token with the Shipments.Write scope on the POST and Shipments.Read on the GET (the scope check pushed to the Operation level). The first deploy 401’d every request — because they had set <audience> to the client app’s ID, not the API’s api:// URI. Five minutes with jwt.ms showed the aud mismatch; correcting the audience fixed it.

The outcome: the next backend deploy generated zero partner-visible errors because the retry storm was absorbed at the gateway; the Bronze partner saw 429s and Retry-After headers and fixed their backoff; Gold traffic was never affected. APIM cost was unchanged (policies are free — they run in the gateway you already pay for), and the backend was downsized from the over-provisioned state the incident had pushed it to. The lesson on the wall: “The gateway is the throttle, the bouncer, and the editor. If the backend is doing any of those three jobs, you put the policy in the wrong place.”

The incident as a timeline, because the order of moves is the lesson:

Time Symptom Action taken Effect What it should have been
14:02 503s for all partners (alert fires) Ask: is the gateway throttling at all?
14:05 Still 503ing Scale backend up 10 min relief, recurs Don’t scale to mask a missing limit
14:20 Root cause: Bronze partner retry storm, no rate limit Add rate-limit-by-key + quota-by-key at Bronze product Storm hits 429 at gateway Correct fix — throttle at the edge
14:40 Review: internal hostname/Server header leaking Global outbound delete + find-and-replace Clean responses Should have been there from day one
+1 day Gap: only subscription key, no JWT Add validate-jwt at API scope First deploy 401s everything Audience set to client ID, not api://
+1 day 401 storm Fix <audience> to the API’s app-ID URI Auth works Always verify aud at jwt.ms

Advantages and disadvantages

Running these controls as gateway policies — rather than in backend code — is a strong default, but it has real trade-offs. Weigh them:

Advantages (why policies at the gateway win) Disadvantages (where it bites)
Centralised: one place to throttle/authenticate/reshape every API, no per-service duplication The policy XML becomes its own codebase; ungoverned, it sprawls across four scopes
Rejected requests cost zero backend invocations (429/401 at the edge) Debugging “why did this 401?” means reasoning about composed policy across scopes
Backend stays simple — it can trust gateway-validated traffic A wrong policy (bad audience, wrong section) can break every API at once
Change the contract (rewrite paths, strip headers) without redeploying backend Transformation hides backend reality — drift between public contract and backend can surprise
Limits/auth are config, shippable via Bicep and reviewed in PRs Rate-limit counters are per region, not a global truth — easy to misjudge
Policy expressions give real logic (claims, variables) without code Expression bugs are runtime-only; weak local testing story vs unit tests

The model is right when you have multiple APIs/partners and want one consistent control plane, when the backend should not (or cannot) own auth and throttling, and when you need to evolve the public contract independently of backends. It bites when teams treat policies as an afterthought, when a single broad-scope policy can take everything down, and when people size limits as if the per-region counter were global. The disadvantages are all governable — keep policies in Bicep, test in a non-prod APIM, scope deliberately — which is what the rest of this article sets you up to do.

Hands-on lab

This is the centrepiece. You will build all three policies end to end on a real APIM instance, validate each with curl, and tear it down. You will do it twice — once in the portal (to see the editor and effective-policy view) and once with az CLI — plus a Bicep version you can commit. Use the Consumption tier (serverless, pay-per-call, cheapest for a lab) or Developer tier (no SLA, full feature set) so cost is negligible.

Prerequisites

# Verify you're logged in and have a subscription selected
az account show --query "{sub:name, id:id}" -o table

# Variables used throughout (edit the names; APIM name must be globally unique)
RG=rg-apim-lab
LOC=centralindia
APIM=apim-lab-$RANDOM            # globally-unique
PUBLISHER_EMAIL=you@example.com
PUBLISHER_NAME="APIM Lab"

Part A — Create the instance and import a test API

Step 1 — Resource group.

az group create -n $RG -l $LOC -o table

Expected: a table row with provisioningState = Succeeded.

Step 2 — Create the APIM instance (Consumption tier). Consumption provisions in a couple of minutes; Developer/other tiers can take 30–45 minutes, so Consumption is best for a lab.

az apim create -n $APIM -g $RG -l $LOC \
  --publisher-email "$PUBLISHER_EMAIL" --publisher-name "$PUBLISHER_NAME" \
  --sku-name Consumption -o table

Expected: a long-running operation that finishes with provisioningState = Succeeded and a gatewayUrl like https://apim-lab-12345.azure-api.net. Capture it:

GATEWAY=$(az apim show -n $APIM -g $RG --query gatewayUrl -o tsv)
echo "$GATEWAY"

Step 3 — Import a public sample API (the Microsoft “Demo Conference” / httpbin-style backend works; here we use the public httpbin.org as a stand-in backend so the lab needs no backend of your own):

az apim api create -g $RG --service-name $APIM \
  --api-id httpbin --path bin \
  --display-name "HTTPBin" \
  --service-url "https://httpbin.org" \
  --protocols https -o table

# Add one operation: GET /get  (httpbin echoes the request back as JSON)
az apim api operation create -g $RG --service-name $APIM --api-id httpbin \
  --operation-id get-anything --display-name "Get" \
  --method GET --url-template "/get" -o table

Expected: the API and an operation, reachable at $GATEWAY/bin/get.

Step 4 — Get a subscription key to call it. Consumption requires a subscription for the built-in all-APIs product. List keys:

# The built-in "master" subscription works for the lab; or use the all-access sub
SUBKEY=$(az apim subscription list -g $RG --service-name $APIM \
  --query "[?scope=='/apis' || contains(scope,'allApis') || displayName=='Built-in all-access subscription'].primaryKey | [0]" -o tsv)
# Fallback: pull the master subscription key
if [ -z "$SUBKEY" ]; then SUBKEY=$(az apim subscription show -g $RG --service-name $APIM --sid master --query primaryKey -o tsv); fi
echo "key length: ${#SUBKEY}"

# Smoke test — should return httpbin's JSON echo
curl -s -o /dev/null -w "%{http_code}\n" "$GATEWAY/bin/get" -H "Ocp-Apim-Subscription-Key: $SUBKEY"

Expected: 200. If you get 401, the subscription key is wrong; if 404, the path/operation is off.

Part B — Policy 1: rate-limit-by-key (portal, then CLI)

Step 5 — Apply the rate limit in the portal. In the portal: APIs → HTTPBin → Design → All operations → Inbound processing → Policies (</> editor). Replace the <inbound> section so it reads:

<inbound>
  <base />
  <rate-limit-by-key calls="5" renewal-period="60"
      counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
      remaining-calls-header-name="X-RateLimit-Remaining"
      retry-after-header-name="Retry-After" />
</inbound>

Click Save. (To see the merged result, switch the editor’s view to Calculated effective policy — you will see <base /> expanded with the global/product policies inline.)

Step 6 — Apply the same rate limit with az (API scope), so you have it as a repeatable command. Write the policy XML to a file and set it at the API scope:

cat > /tmp/ratelimit-policy.xml <<'XML'
<policies>
  <inbound>
    <base />
    <rate-limit-by-key calls="5" renewal-period="60"
        counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
        remaining-calls-header-name="X-RateLimit-Remaining"
        retry-after-header-name="Retry-After" />
  </inbound>
  <backend><base /></backend>
  <outbound><base /></outbound>
  <on-error><base /></on-error>
</policies>
XML

az apim api policy create -g $RG --service-name $APIM --api-id httpbin \
  --policy-format xml --value "$(cat /tmp/ratelimit-policy.xml)" -o table
# (use `az apim api policy update` if a policy already exists)

Expected: the command returns the stored policy.

Step 7 — Validate the rate limit. Fire 8 calls; the first 5 succeed (200), the rest are throttled (429):

for i in $(seq 1 8); do
  printf "call %s -> " "$i"
  curl -s -o /dev/null -w "%{http_code}  Retry-After=%header{retry-after}  Remaining=%header{x-ratelimit-remaining}\n" \
    "$GATEWAY/bin/get" -H "Ocp-Apim-Subscription-Key: $SUBKEY"
done

Expected: calls 1–5 return 200 with a decreasing X-RateLimit-Remaining; calls 6–8 return 429 with a Retry-After value in seconds. (If your curl is older and %header{} is unsupported, drop those format fields and just observe the status codes.)

Part C — Policy 2: validate-jwt

Step 8 — Add JWT validation at the API scope. For the lab, you need an OIDC provider. If you have an Entra tenant, point at its metadata; the policy below requires a valid token for your tenant. (If you do not want to mint a token, you can still prove the policy works by confirming an unauthenticated call now returns 401.)

TENANT_ID=$(az account show --query tenantId -o tsv)

cat > /tmp/jwt-policy.xml <<XML
<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401"
                  failed-validation-error-message="Unauthorized. Token missing or invalid.">
      <openid-config url="https://login.microsoftonline.com/$TENANT_ID/v2.0/.well-known/openid-configuration" />
      <issuers>
        <issuer>https://login.microsoftonline.com/$TENANT_ID/v2.0</issuer>
      </issuers>
      <!-- Add your API's Application ID URI once you register it:
      <audiences><audience>api://YOUR-API-APP-ID</audience></audiences>
      -->
    </validate-jwt>
    <rate-limit-by-key calls="5" renewal-period="60"
        counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
        remaining-calls-header-name="X-RateLimit-Remaining"
        retry-after-header-name="Retry-After" />
  </inbound>
  <backend><base /></backend>
  <outbound><base /></outbound>
  <on-error><base /></on-error>
</policies>
XML

az apim api policy update -g $RG --service-name $APIM --api-id httpbin \
  --policy-format xml --value "$(cat /tmp/jwt-policy.xml)" -o table

Note the ordering: validate-jwt runs before rate-limit-by-key, so an unauthenticated flood is rejected as 401 before it even consumes the rate-limit budget — authenticate first, throttle second.

Step 9 — Validate JWT rejection and acceptance.

# No token -> 401 (validate-jwt rejects before the backend is touched)
curl -s -o /dev/null -w "no-token -> %{http_code}\n" \
  "$GATEWAY/bin/get" -H "Ocp-Apim-Subscription-Key: $SUBKEY"

# A real token for a registered API audience -> 200.
# Acquire one for your API (replace api://YOUR-API-APP-ID with your App ID URI):
TOKEN=$(az account get-access-token --resource api://YOUR-API-APP-ID --query accessToken -o tsv 2>/dev/null)
if [ -n "$TOKEN" ]; then
  curl -s -o /dev/null -w "with-token -> %{http_code}\n" \
    "$GATEWAY/bin/get" -H "Ocp-Apim-Subscription-Key: $SUBKEY" -H "Authorization: Bearer $TOKEN"
fi

Expected: the no-token call returns 401. With a valid token whose aud matches your <audience>, the call returns 200. If you get 401 with a token, decode it at jwt.ms and compare its aud and iss to your policy — an audience mismatch is the number-one cause.

Part D — Policy 3: request/response transformation

Step 10 — Add transforms: inject a request header, strip response internals, rewrite a value. Replace the policy with the full three-policy stack:

cat > /tmp/full-policy.xml <<XML
<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401"
                  failed-validation-error-message="Unauthorized. Token missing or invalid.">
      <openid-config url="https://login.microsoftonline.com/$TENANT_ID/v2.0/.well-known/openid-configuration" />
      <issuers><issuer>https://login.microsoftonline.com/$TENANT_ID/v2.0</issuer></issuers>
    </validate-jwt>
    <rate-limit-by-key calls="5" renewal-period="60"
        counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
        remaining-calls-header-name="X-RateLimit-Remaining"
        retry-after-header-name="Retry-After" />
    <set-header name="X-Forwarded-Host" exists-action="override">
      <value>@(context.Request.OriginalUrl.Host)</value>
    </set-header>
  </inbound>
  <backend><base /></backend>
  <outbound>
    <base />
    <set-header name="Server" exists-action="delete" />
    <set-header name="X-Powered-By" exists-action="delete" />
    <set-header name="X-APIM-Gateway" exists-action="override">
      <value>mintleaf</value>
    </set-header>
    <find-and-replace from="httpbin.org" to="api.contoso.com" />
  </outbound>
  <on-error><base /></on-error>
</policies>
XML

az apim api policy update -g $RG --service-name $APIM --api-id httpbin \
  --policy-format xml --value "$(cat /tmp/full-policy.xml)" -o table

Step 11 — Validate the transforms. Inspect the response headers and body:

# (Use a valid token if you kept validate-jwt strict; or temporarily comment it out)
curl -s -D - "$GATEWAY/bin/get" -H "Ocp-Apim-Subscription-Key: $SUBKEY" -H "Authorization: Bearer $TOKEN" | sed -n '1,25p'

Expected: the response carries X-APIM-Gateway: mintleaf, no Server header from the backend, and any httpbin.org string in the JSON body is rewritten to api.contoso.com. The request echo from httpbin shows the injected X-Forwarded-Host header proving the inbound transform fired.

Part E — Bicep (commit this, not click-ops)

Step 12 — The same three policies as Bicep, so your gateway config lives in source control. This deploys the API-scope policy document:

@description('Existing APIM instance name')
param apimName string
param tenantId string = subscription().tenantId

resource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' existing = {
  name: apimName
}

resource api 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' existing = {
  parent: apim
  name: 'httpbin'
}

// The full inbound/outbound policy document at API scope
resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2023-05-01-preview' = {
  parent: api
  name: 'policy'
  properties: {
    format: 'rawxml'
    value: '''
<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401">
      <openid-config url="https://login.microsoftonline.com/${tenantId}/v2.0/.well-known/openid-configuration" />
      <issuers><issuer>https://login.microsoftonline.com/${tenantId}/v2.0</issuer></issuers>
    </validate-jwt>
    <rate-limit-by-key calls="5" renewal-period="60"
        counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)"
        remaining-calls-header-name="X-RateLimit-Remaining"
        retry-after-header-name="Retry-After" />
  </inbound>
  <backend><base /></backend>
  <outbound>
    <base />
    <set-header name="Server" exists-action="delete" />
    <set-header name="X-Powered-By" exists-action="delete" />
  </outbound>
  <on-error><base /></on-error>
</policies>
'''
  }
}

Deploy and validate:

az deployment group create -g $RG \
  --template-file ./apim-policy.bicep \
  --parameters apimName=$APIM -o table

Expected: provisioningState = Succeeded; re-running Step 7 and Step 9 shows the same throttling and 401 behaviour, now reproducible from code.

Validation checklist and teardown

What each part proved, mapped to the real-world job:

Part What you did What it proves Real-world analogue
B rate-limit-by-key, 8 calls 6th call 429s at the gateway Absorbing a retry storm at the edge
C validate-jwt, no token Unauth request 401s before backend Bouncing leaked-key/anon traffic
C Token with wrong aud 401 until audience corrected The #1 JWT misconfig in the wild
D set-header delete + find-and-replace Internals stripped, hostname rewritten Not leaking backend details to partners
E Bicep policy document Same behaviour, reproducible from code Governed, reviewed gateway config

Teardown (stops all charges — Consumption is pay-per-call, but delete to be clean):

az group delete -n $RG --yes --no-wait

Cost note. On the Consumption tier you pay per call (a generous monthly free grant covers a lab’s handful of requests), so this lab costs effectively nothing. On the Developer tier it is a fixed low hourly rate (~₹4/hour) with no SLA — fine for a few hours, but delete the resource group when done so the meter stops.

Common mistakes & troubleshooting

The failures you will actually hit, as a scannable table first, then the high-bite ones expanded.

# Symptom Root cause Confirm (exact check) Fix
1 401 even with a valid token <audience> set to client ID, not the API’s api:// URI Decode token at jwt.ms; compare aud to <audience> Set <audience> to the API’s Application ID URI
2 401 on all requests, no keys fetched openid-config url wrong/unreachable, or gateway has no egress Open the URL in a browser; check gateway outbound Correct URL; allow egress to login.microsoftonline.com
3 401 from a v1-flow token vs v2 issuer Issuer mismatch (sts.windows.net vs …/v2.0) Compare token iss to <issuer> Match issuer to the endpoint clients use
4 429 arrives sooner than expected Limit applied at multiple scopes (product and API) — both count Check Product + API + Operation policies Apply the limit at one scope; remove duplicates
5 429 arrives later than expected (over-admits) Counter is per region; multi-region multiplies the cap Count regions/units behind the limit Size per region, or use quota-by-key/external cache
6 Response set-header does nothing Placed in inbound instead of outbound Read the policy; check the section Move response transforms to outbound
7 Policy save fails with a validation error Malformed XML, unknown attribute, expression typo Portal editor underlines the line; error message names it Fix XML; validate @(...) expression syntax
8 rate-limit-by-key throttles everyone together counter-key evaluates to a constant for all callers Log/trace the computed counter-key Key on subscription/IP/claim, not a fixed string
9 Backend gets a 404 after rewrite-uri template points at a path the backend doesn’t have Compare rewritten path to backend routes Fix the rewrite-uri template
10 Retry-After/remaining headers missing Header-name attributes not set on the policy Check retry-after-header-name/remaining-calls-header-name Add the header-name attributes
11 validate-jwt passes but scope check should fail required-claims not set, or match too loose Inspect the policy’s <required-claims> Add the scope/role claim; set match correctly
12 500 from on-error itself A policy in on-error threw (e.g. read a missing variable) Trace the on-error section Keep on-error minimal and exception-safe

1. 401 even though the token is valid. The token’s aud claim does not match your <audience>. In Entra, the audience of an access token for your API is the API’s Application ID URI (api://<api-app-id> or a custom URI), not the client app’s ID and not Graph. Confirm by pasting the raw token into jwt.ms and comparing aud to your <audience> value — they must be byte-identical. Fix by setting <audience> to the exact URI the token carries (and expose the right scope on the API app registration so clients can request it).

2. Every request 401s and the gateway seems to have no keys. validate-jwt could not fetch the signing keys from openid-config url — either the URL is wrong (a typo’d tenant ID, a v1/v2 mismatch in the metadata path) or the gateway has no outbound route to login.microsoftonline.com. Confirm by opening the exact …/.well-known/openid-configuration URL in a browser (it should return JSON with a jwks_uri). Fix the URL; if you run a VNet-injected or self-hosted gateway, ensure egress to the IdP is allowed.

4 & 5. 429 at the wrong time. Two opposite failures. Sooner than expected: you applied the limit at both the Product and the API (or Operation) scope, and they count independently, so the effective ceiling is lower than either number suggests. Resolve by choosing one scope for the limit. Later than expected / over-admitting: the counter is per gateway region — a two-region deployment admits roughly double. This is by design; size the per-region calls accordingly, or switch to quota-by-key over a longer window where the drift is negligible, or use an external Redis cache on a self-hosted gateway for shared counters.

6. A response transform does nothing. set-header, find-and-replace, and set-body only affect the response when they sit in the outbound section. The same statement in inbound transforms the request (or no-ops if the header is response-only). Confirm by reading which section the statement is in; move it to outbound. Remember the corollary: outbound is skipped on an error path, so if a header must be stripped even on errors, repeat it in on-error.

8. The rate limit throttles all callers as one. Your counter-key evaluates to the same value for everybody — for example a literal string, or context.Subscription?.Key on an API where no subscription is required (so it is null for all). Confirm by adding a temporary <trace> that logs the computed key, or reason it through. Fix by keying on something that actually varies per caller: subscription ID, client IP, a per-user header, or a JWT claim.

Best practices

Security notes

The controls that secure and harden, side by side:

Control Mechanism Secures against Also prevents
JWT validation at edge validate-jwt (aud/iss/scp) Anonymous/leaked-key access Backend duplicating auth logic
Scope enforcement required-claims Over-privileged token use Wrong operation called with broad token
Header stripping set-header … delete (Global) Internal info disclosure Fingerprinting your stack
Secrets as named values Key Vault reference + MI Secrets in plaintext policy/repo Manual rotation breaking the gateway
Rate/quota limits rate-limit-by-key/quota-by-key Abuse, runaway cost One client starving the rest
Private networking Front Door + Private Endpoint Direct backend hits bypassing gateway Throttle/auth being skipped

Cost & sizing

The good news: policies themselves are free. They run inside the APIM gateway you already pay for — adding rate-limit-by-key, validate-jwt, and transforms costs zero extra. The bill is the APIM tier, and the policies are precisely what let you run a smaller tier safely (by absorbing abuse at the edge instead of over-provisioning the backend). The drivers:

A rough monthly picture and what each tier buys:

Tier Rough cost Policies supported Use it for Watch-out
Consumption Pay-per-call (free grant covers a lab) Full policy set (some limits) Spiky/low volume, serverless No VNet injection; per-call counters less global
Developer ~₹3,000–3,500/month (or hourly) Full Non-prod, testing policies No SLA — never production
Basic ~₹12,000–15,000/month per unit Full Small prod, no VNet No multi-region
Standard ~₹48,000/month per unit Full Mainstream prod VNet via Standard v2
Premium ~₹2,40,000+/month per unit Full + multi-region Large/regulated, multi-region Highest floor; per-region counters

The sizing rule: pick the tier for the features and throughput you need (VNet, multi-region, capacity), then lean on free policies to keep that tier as small as it can safely be. A rate-limit-by-key that stops a retry storm is the cheapest “capacity” you will ever add.

Interview & exam questions

1. What are the four sections of an APIM policy document and when does each run? inbound (on the way in — throttle, authenticate, reshape the request), backend (around the backend call — retries, timeouts, forward-request), outbound (on the way out — reshape the response, strip headers), and on-error (only if a section throws). The order is inbound → backend → outbound, with on-error as the exception path; outbound is skipped on an error unless you resume it.

2. What does <base /> do and what happens if you omit it? <base /> marks where the parent scope’s matching section is injected when policies are composed across Global → Product → API → Operation. Placed first, the parent runs before your statements; placed last, your statements run first; omitted, the parent section is skipped entirely for that scope. It is how you control parent-vs-child ordering and overrides.

3. Why is a rate-limit-by-key counter described as “per region,” and why does it matter? APIM maintains the throttling counter per gateway region (and per replica on a self-hosted gateway without an external cache) to keep it low-latency and distributed. In a multi-region deployment a “100/60s” limit can admit roughly 100 per region, so the global ceiling is higher than the number suggests. You must size limits per region or use quota-by-key/an external cache for a hard shared count.

4. Difference between rate-limit-by-key and quota-by-key? rate-limit-by-key smooths bursts over a short window (≤ 300 s) and returns 429 when exceeded; quota-by-key caps a total allowance over a long renewable window (e.g. per day/month) and returns 403 when exhausted. Use rate limiting for “requests per second,” quota for “calls per month.”

5. A request with a valid Entra token still gets 401 from validate-jwt. Most likely cause? An audience mismatch: <audience> is set to the client app’s ID (or Graph) instead of the called API’s Application ID URI (api://<api-app-id>). The token’s aud claim must exactly match <audience>. Confirm by decoding the token at jwt.ms; fix by setting the correct audience and exposing the right scope on the API app registration.

6. How does validate-jwt get the keys to verify a token’s signature? It fetches them from the issuer’s OpenID configuration at the openid-config url you specify (which points to a jwks_uri), and caches/refreshes them — so signing-key rotation is handled automatically. You configure metadata URLs and required audiences/issuers/required-claims, not raw keys.

7. Where should you place a policy that strips the Server header, and in which section? At the Global scope (so it applies to every API) in the outbound section (because it transforms the response). A response set-header … exists-action="delete" in inbound would do nothing.

8. What is the difference between exists-action="override" and "append" on set-header? override replaces the header’s value if it already exists; append adds an additional value (for multi-value headers). skip leaves an existing value (set-a-default-if-absent), and delete removes the header.

9. You apply a rate limit at both the Product and the API scope. What happens? Both counters apply independently, so the effective limit is tighter than either number alone implies (a caller can be throttled by whichever fills first). Apply the limit at a single, deliberate scope to avoid a confusing effective ceiling.

10. Why authenticate (validate-jwt) before throttling (rate-limit-by-key) in inbound? So an unauthenticated flood is rejected with 401 before it consumes rate-limit budget or touches transforms — it costs nothing and doesn’t let anonymous traffic exhaust a legitimate caller’s allowance. Order in inbound is significant; authentication is the cheapest, earliest gate.

11. A response transform you wrote isn’t taking effect. What’s the first thing to check? Which section it’s in: response transforms (set-header, find-and-replace, set-body) must be in outbound. The same statement in inbound affects the request, not the response. Also check that the request didn’t error (then outbound is skipped and on-error runs instead).

12. How do you keep backend secrets out of policy XML? Store them as named values (marked secret, or backed by Key Vault with the APIM managed identity granted Key Vault Secrets User) and reference them from policies. Never inline credentials in the XML, and never commit them in plaintext.

These map mainly to AZ-204 (Developer Associate)implement API Management, policies, and securing APIs — with the identity/JWT angle touching AZ-500 and the networking posture AZ-700. A compact cert map for revision:

Question theme Primary cert Objective area
Policy sections, <base />, scopes AZ-204 Implement API Management
rate-limit-by-key / quota-by-key AZ-204 Configure API policies
validate-jwt, audiences/issuers/claims AZ-204 / AZ-500 Secure APIs; protect with OAuth2/OIDC
Named values / Key Vault references AZ-204 / AZ-500 Manage secrets; secure config
Private networking / multi-region AZ-700 Network connectivity for APIM

Quick check

  1. In which policy section do you put validate-jwt, and what status code does it return on failure?
  2. Your rate-limit-by-key is configured for 100 calls/60 s, but in a two-region deployment callers seem to get through ~200/60 s. Why?
  3. A set-header name="Server" exists-action="delete" isn’t removing the header. Name the most likely placement mistake.
  4. A caller presents a valid Entra token and still gets 401. What single value do you compare at jwt.ms, and against what?
  5. You want plan-based throttling (Free vs Paid) — at which scope do you place the rate-limit policy, and why?

Answers

  1. In the inbound section (authenticate on the way in, before throttling and transforms). It returns 401 Unauthorized by default (configurable via failed-validation-httpcode).
  2. The rate-limit-by-key counter is maintained per gateway region, so each region independently admits up to ~100/60 s — the global total is roughly the sum across regions. Size per region, or use quota-by-key/an external cache for a hard shared ceiling.
  3. It’s in the inbound section instead of outbound. Response transforms only affect the response when placed in outbound.
  4. Compare the token’s aud (audience) claim to your policy’s <audience> value — they must be identical. The usual bug is <audience> set to the client app ID instead of the API’s api://<api-app-id> Application ID URI.
  5. At the Product scope, because the limit is a property of the plan (the product) and is shared across every API in that product — Free and Paid become two products with different calls/renewal-period.

Glossary

Next steps

You can now ship the three policies every API needs. Build outward:

AzureAPI ManagementAPIMPoliciesRate LimitingJWTOAuth2Bicep
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading