Policies in Depth
Why this matters
Policies are what make APIM more than a reverse proxy. Everything you'd otherwise scatter across backend code or nginx configs — rate limiting, quotas, IP filtering, header rewriting, response shaping — lives in one declarative XML pipeline the gateway runs on every request. This is the single most important module in the track: modules 04 (JWT validation) and 05 (backend routing) are specific policies, and the capstone is mostly "write the right policies at the right scope." Learn the pipeline shape here and the rest is vocabulary.
Concepts
The four-section policy pipeline
Every policy document has the same skeleton, and order is everything:
<policies>
<inbound> <!-- runs on the request, before the backend --> </inbound>
<backend> <!-- controls the call to the backend itself --> </backend>
<outbound> <!-- runs on the response, after the backend --> </outbound>
<on-error> <!-- runs only if something above threw --> </on-error>
</policies>
A request flows inbound → backend → outbound; if any section throws, control
jumps to on-error. <inbound> is where you authenticate, throttle, and
rewrite the request (this is where rate-limit and validate-jwt go).
<backend> wraps the actual forward call (<forward-request>) and is where you
set timeouts/retries and choose among backends (module 05). <outbound> rewrites
the response — strip internal headers, add a Sunset header, transform a body.
<on-error> shapes what the consumer sees when things fail. The magic string
<base /> in each section means "run the inherited policy from the parent
scope here" — that's how scopes compose (next concept).
Scopes and <base /> inheritance
Policies attach at four scopes, from broad to narrow: All APIs (global) →
Product → API → Operation. At request time APIM evaluates them
outside-in, and each narrower scope's <base /> element marks where the
enclosing scope's policy runs. So an operation policy of
<inbound><base /><rate-limit .../></inbound> means "run everything inherited
from API/product/global first (<base />), then my rate limit." Move <base />
after your policy and yours runs first. This is the same "narrower scope inherits
and can extend/override broader scope" idea you saw with Kubernetes namespaces vs.
cluster scope, and with policy inheritance in track 06's Container Apps config —
here it's explicit via <base />. Getting <base /> placement wrong (or omitting
it) is a top source of "why did my policy not run / run in the wrong order."
The policy expression language
Beyond static XML, policy values can be C#-based expressions in @(...) (a
single expression) or @{ ... } (a statement block returning a value). They read
from a context object exposing the request, response, user, subscription, and
variables — e.g. @(context.Request.IpAddress),
@(context.Subscription.Id), @(context.Request.Headers.GetValueOrDefault("Authorization","")).
You use expressions to make policies dynamic: rate-limit keyed by the caller's
subscription or IP, set a header from a claim, or branch with <choose>. You
don't need deep C# — you need context.Request, context.Response,
context.Subscription, context.User, and context.Variables. The exercises
use only a handful.
Rate limiting vs. quota (and the scope trap)
Two throttling policies that people constantly confuse:
rate-limit— a short-window burst control: "N calls perrenewal-periodseconds," e.g. 10 calls / 60 s. Protects the backend from spikes. Resets every window.quota— a long-window volume cap: "N calls per day/week/month," e.g. 10000 calls / month. Enforces a plan. Resets on the long period.
Both come in two forms: plain (rate-limit, quota) which count per the scope
they're attached to, and -by-key (rate-limit-by-key, quota-by-key)
which count per a key expression you supply — typically the subscription id or
caller IP. The classic bug (you'll fix it below) is attaching a plain rate-limit
at a scope that lumps all consumers into one shared counter, so a handful of
legitimate callers collectively trip a limit meant to be per consumer. The fix
is rate-limit-by-key keyed by @(context.Subscription.Id).
The workhorse transformation & filtering policies
A handful of policies cover most real needs:
ip-filter(inbound) — allow/deny by client IP/CIDR; the gateway-level analogue of the L7 access restriction you set on a Container App in track 06.set-header/set-body— add/modify/remove request or response headers and bodies (e.g. strip an internalServerheader outbound, inject a correlation id inbound).rewrite-uri— remap the incoming path to a different backend path.choose(if/else) — branch on an expression (e.g. apply a policy only for a specific product or when a header is present).cache-lookup/cache-store— response caching (not on Consumption's internal cache; needs an external cache there).
These are the building blocks; module 04 adds validate-jwt and module 05 adds
set-backend-service/load balancing.
Command reference
Policies are XML applied to a scope. The CLI applies a policy file to an API or operation; the XML itself is the real "command," so the breakdowns below explain the XML.
| Command | What it does | Example |
|---|---|---|
az apim api policy create |
Apply a policy XML file to an API scope | az apim api policy create --resource-group rg --service-name <inst> --api-id orders --policy-format xml --value "@policy.xml" |
az apim api policy show |
Show the current API-scope policy | az apim api policy show --resource-group rg --service-name <inst> --api-id orders |
az apim api operation policy create |
Apply a policy to a single operation | az apim api operation policy create --resource-group rg --service-name <inst> --api-id orders --operation-id root --policy-format xml --value "@op.xml" |
az apim product policy create |
Apply a policy at product scope | az apim product policy create --resource-group rg --service-name <inst> --product-id starter --policy-format xml --value "@prod.xml" |
az apim api policy delete |
Remove an API-scope policy | az apim api policy delete --resource-group rg --service-name <inst> --api-id orders |
XML snippets explained:
Rate limit per consumer (the correct, keyed form):
<inbound>
<base />
<rate-limit-by-key calls="10" renewal-period="60"
counter-key="@(context.Subscription.Id)" />
</inbound>
calls="10"/renewal-period="60"— allow 10 calls per 60-second window.counter-key="@(context.Subscription.Id)"— the counter is per subscription, so each consumer gets their own budget (not a shared pool). Use@(context.Request.IpAddress)to key by client IP instead.<base />first — inherited (product/global) policies run before this.
Monthly quota per consumer:
<quota-by-key calls="10000" renewal-period="2592000"
counter-key="@(context.Subscription.Id)" />
renewal-period="2592000"— 30 days in seconds; a long-window volume cap (a plan limit), distinct from the short-window burst limit above.
IP allow-list (gateway analogue of track 06's access restriction):
<ip-filter action="allow">
<address>203.0.113.10</address>
<address-range from="198.51.100.0" to="198.51.100.255" />
</ip-filter>
action="allow"— only listed IPs/ranges pass; everything else is denied (useaction="forbid"for a deny-list).
Outbound header hygiene + a deprecation signal:
<outbound>
<base />
<set-header name="Server" exists-action="delete" />
<set-header name="Sunset" exists-action="override">
<value>Sat, 31 Jan 2026 23:59:59 GMT</value>
</set-header>
</outbound>
exists-action="delete"— strip the backend'sServerheader from the response (don't leak backend detail).exists-action="override"— set/replaceSunset— the deprecation header from module 02's deprecation strategy.
Hands-on exercises
Time note: reuse a Consumption instance (fast). Nothing here needs a classic tier. Save each XML snippet to a local file and apply it with the CLI.
Instance + backend + API. Stand up the same base as module 02 (Consumption APIM, a Container App
orders, anordersAPI with aGET /operation, grab$keyand$gw). Confirmcurl -H "Ocp-Apim-Subscription-Key: $key" "$gw/orders/"returns 200 before adding policies.Apply a per-consumer rate limit (correct form). Save as
ratelimit.xml:<policies> <inbound> <base /> <rate-limit-by-key calls="5" renewal-period="60" counter-key="@(context.Subscription.Id)" /> </inbound> <backend><base /></backend> <outbound><base /></outbound> <on-error><base /></on-error> </policies>az apim api policy create --resource-group rg-apim-m03 --service-name $apim --api-id orders ` --policy-format xml --value "@ratelimit.xml"Fire 8 quick calls; the 6th within the window returns 429 Too Many Requests. That's the burst limit working per consumer.
Add a monthly quota. Extend the inbound section with a
quota-by-key(10000/30 days) and re-apply. Confirm normal calls still succeed — the quota only bites at high volume, so you're verifying it doesn't break anything, not that you can hit 10000 calls.Strip an internal header outbound. Add to
<outbound>:<set-header name="Server" exists-action="delete" />, re-apply, andcurl -ithe endpoint. Confirm the backend'sServerheader no longer appears in the response — outbound transformation in action.IP allow-list, then deliberately lock yourself out. Add an inbound
ip-filter action="allow"listing a bogus IP (not yours), re-apply, andcurl -i. Expect 403 — you're not on the list. This sets up the diagnose-and-fix in exercise 8, but first observe the raw block.Branch with
<choose>. Add an inbound rule that injects a header only when a query param is present:<choose> <when condition="@(context.Request.Url.Query.GetValueOrDefault("debug","") == "1")"> <set-header name="X-Debug" exists-action="override"><value>on</value></set-header> </when> </choose>Re-apply, then call with and without
?debug=1and confirm the header only appears when the condition matches. This is the expression language driving control flow.Operation-scope vs API-scope +
<base />order. Put a stricter rate limit (2/60) at the operation scope with<base />after it, so the operation limit runs before the inherited API limit. Observe which limit trips first and why. Move<base />before it and observe the order change. This makes<base />placement concrete.Diagnose and fix: a rate-limit policy blocking legitimate traffic (wrong scope/key). This is the classic. Replace the keyed limit with a plain
rate-limitat API scope:<inbound> <base /> <rate-limit calls="5" renewal-period="60" /> </inbound>Now have two different consumers call (use two subscription keys — create a second subscription, or reuse
masterplus a product key from module 06's pattern). Notice that their calls share one counter, so a handful of legitimate callers collectively trip the 5-call limit and get 429s even though none individually is abusive. Diagnose: plainrate-limitcounts per the attached scope, pooling all consumers. Fix: switch back torate-limit-by-keykeyed by@(context.Subscription.Id)so each consumer has an independent budget. Re-test with both consumers and confirm neither is throttled by the other's traffic. Lesson: throttling scope/key is the thing to check when legitimate traffic is being blocked.Diagnose and fix: locked out by your own IP filter (from exercise 5). Your
curlreturns 403 because the allow-list doesn't include your IP. Diagnose by reading the applied policy (az apim api policy show ...) and comparing the listed addresses to your real egress IP (curl https://api.ipify.org). Fix by adding your IP to the allow-list (or removing the filter). Confirm 200 returns. Lesson:ip-filter action="allow"is deny-by-default for everyone not listed.Cleanup.
az group delete --name rg-apim-m03 --yes --no-wait
Independent challenge
Compose a single API-scope policy document that does all of the following at the
correct scope and in the correct order: rejects any caller outside an IP range
you choose, enforces a per-consumer burst limit and a per-consumer monthly
quota, strips a backend header on the way out, and injects a Sunset header to
foreshadow deprecation of this version (tie back to module 02's deprecation
strategy). Then break it on purpose in the specific way that throttles legitimate
traffic (wrong throttle scope/key) and fix it, writing down the exact symptom and
the one-line change. Use only policies from this module — no validate-jwt yet
(that's module 04). Draw on module 00's pipeline model to justify why each
policy sits in inbound vs. outbound. Delete the resource group (and the APIM
instance) when done.
Stuck? One hint
Put request-side controls (ip-filter, rate-limit-by-key, quota-by-key) in
<inbound> and response-side changes (set-header delete/override) in
<outbound>, each right after <base />. The throttle bug is always the same:
plain rate-limit/quota share one counter across all consumers at that scope,
so replace them with the -by-key variants keyed by
@(context.Subscription.Id) to give each consumer its own budget.
Common mistakes & troubleshooting
- Missing or misplaced
<base />. Omit it and you silently drop inherited policies (auth, product limits); misplace it and your policy runs in the wrong order. Almost every "my policy didn't run right" traces here. - Plain
rate-limit/quotawhere you meant per-consumer. The plain form pools all callers at the attached scope, throttling legitimate traffic. Use the-by-keyvariant keyed by subscription id or IP for per-consumer limits. - Confusing rate-limit with quota. Rate-limit = short-window burst (calls per seconds); quota = long-window volume (calls per day/month). You usually want both, for different reasons.
ip-filter action="allow"locks everyone out. Allow-lists are deny-by-default; forgetting your own egress IP returns 403 to you. Verify your IP withapi.ipify.org.- Applying a policy to the wrong version/revision. As in module 02, a policy
on
v1doesn't apply tov2, and one on a non-current revision isn't live. - Expression errors fail the policy. A malformed
@(...)can 500 the request viaon-error. Test expressions incrementally; keep them simple. - Cost pitfall. Policies themselves are free, but response caching
(
cache-store) needs an external Redis cache on Consumption — adding one is a real, billable resource. Don't provision a cache you don't need for these exercises, and if you do, delete it with the group.
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.
- Name the four policy sections in execution order and what each is for.
- What does
<base />do, and what breaks if you omit it in a narrower scope? - List the four policy scopes from broadest to narrowest.
- Distinguish
rate-limitfromquota, and the plain form from the-by-keyform. - Legitimate consumers are getting 429s even though none is abusive. What's the likely cause and the fix?
- Where would you put a policy that strips a backend
Serverheader, and where would you put JWT validation (previewing module 04)? - Write the counter-key expression that makes a rate limit per-consumer.
- Your
ip-filter action="allow"returns 403 to you. Why, and how do you confirm it?
Show answers
- inbound (request, pre-backend: auth, throttle, rewrite) → backend (the forward call: timeouts, backend selection) → outbound (response rewrite) → on-error (runs only if something threw).
<base />runs the inherited policy from the parent scope at that point. Omit it and inherited policies (e.g. product/global auth and limits) are dropped for that section.- All APIs (global) → Product → API → Operation.
rate-limit= short-window burst (calls per seconds);quota= long-window volume (calls per day/month). Plain forms count per the attached scope (shared across consumers);-by-keyforms count per your key expression (per consumer/IP).- A plain
rate-limitat a scope that pools all consumers into one counter. Fix: userate-limit-by-keykeyed by@(context.Subscription.Id)so each consumer gets an independent budget. Server-header strip goes in<outbound>(set-header exists-action= "delete"); JWT validation goes in<inbound>(validate-jwt), before the backend call.counter-key="@(context.Subscription.Id)"(or@(context.Request.IpAddress)to key by IP).ip-filter action="allow"is deny-by-default; your egress IP isn't on the list. Confirm by comparing the policy's addresses tocurl https://api.ipify.org.
Next
04-authentication-and-authorization-at-the-gateway
— move from "who has a key" to real identity: subscription keys vs. OAuth2/JWT,
validating an Entra ID token with validate-jwt (ties into track 16), and client
certificate authentication.