CORS in Azure API Management looks easy right up until the browser starts throwing useless errors and your API works fine in Postman.
That’s the trap: CORS is a browser enforcement layer, and APIM adds another layer of policy behavior on top of it. If you put the policy in the wrong scope, return the wrong origin, or forget how preflight requests work, you get a mess that’s hard to debug.
Here are the mistakes I see most often with CORS in Azure API Management, and the fixes that actually work.
Mistake 1: Testing CORS with Postman or curl and assuming it’s fine
This is the classic one.
You call your APIM endpoint with curl, get a 200 OK, and decide CORS is configured correctly. Then the frontend sends the same request from https://app.example.com and the browser blocks it.
That happens because CORS is enforced by browsers, not by your API client.
What to do instead
Test with a real browser request, or at least inspect the response headers carefully.
A browser cares about headers like:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Expose-Headers: ETag, Link, Location
A real-world example: api.github.com returns:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That access-control-expose-headers list is a good reminder: if your frontend needs to read a non-simple response header, you must expose it explicitly.
Mistake 2: Putting the cors policy in the wrong scope
APIM lets you define policies at multiple levels: global, product, API, and operation.
People often set CORS at one level and assume it applies cleanly everywhere. Then one operation behaves differently, or a product-level policy clashes with an API-level policy.
What goes wrong
- One API allows
https://app.example.com - Another operation overrides the policy
- Preflight succeeds on one route and fails on another
- You spend an hour blaming the backend
Fix
Keep CORS policy placement boring and predictable.
If the whole API should behave the same way, put the cors policy at the API scope. Only go narrower if you have a real reason.
A basic APIM CORS policy looks like this:
<inbound>
<base />
<cors allow-credentials="true">
<allowed-origins>
<origin>https://app.example.com</origin>
</allowed-origins>
<allowed-methods preflight-result-max-age="300">
<method>GET</method>
<method>POST</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>authorization</header>
<header>content-type</header>
</allowed-headers>
<expose-headers>
<header>etag</header>
<header>location</header>
<header>x-request-id</header>
</expose-headers>
</cors>
</inbound>
If you need operation-specific behavior, document it well. Future-you will forget.
Official docs: Azure API Management policy reference - CORS policy
Mistake 3: Using * with credentials
This one never dies.
You want cookies or authorization-backed browser sessions, so you enable credentials. Then you also configure:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject that combination.
Why
When credentials are involved, the server must return a specific origin, not a wildcard.
Fix
Return the exact allowed origin.
In APIM:
<cors allow-credentials="true">
<allowed-origins>
<origin>https://app.example.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>POST</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>authorization</header>
<header>content-type</header>
</allowed-headers>
</cors>
If your API is truly public and does not use credentials, * is fine:
<cors allow-credentials="false">
<allowed-origins>
<origin>*</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>OPTIONS</method>
</allowed-methods>
</cors>
That’s roughly the model GitHub uses for public API access with:
access-control-allow-origin: *
Mistake 4: Forgetting preflight requests entirely
A lot of CORS failures are really preflight failures.
If the browser sends:
OPTIONSAccess-Control-Request-MethodAccess-Control-Request-Headers
then APIM needs to answer correctly before the real request is even attempted.
Common symptoms
GETworks,POSTfails- Requests with
Authorizationfail - Requests with
Content-Type: application/jsonfail - Browser says “blocked by CORS policy” and the backend never sees the real call
Fix
Make sure your APIM cors policy allows:
- the origin
- the actual method
- every non-simple request header the browser wants to send
Example:
<cors allow-credentials="true">
<allowed-origins>
<origin>https://app.example.com</origin>
</allowed-origins>
<allowed-methods preflight-result-max-age="300">
<method>GET</method>
<method>POST</method>
<method>PUT</method>
<method>DELETE</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>authorization</header>
<header>content-type</header>
<header>x-request-id</header>
</allowed-headers>
</cors>
If your frontend sends x-api-key and you forgot to allow it, preflight fails. The browser won’t negotiate. It just blocks.
Official docs: How CORS works in Azure API Management
Mistake 5: Exposing too few response headers
Your frontend can see the response body, but not the header it needs.
This usually shows up when the app tries to read:
ETagLocation- rate limit headers
- custom tracing headers
Developers often assume that if the header exists in the response, JavaScript can read it. Not true.
Fix
Use expose-headers for anything the frontend needs beyond simple response headers.
Example:
<cors>
<allowed-origins>
<origin>https://app.example.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>OPTIONS</method>
</allowed-methods>
<expose-headers>
<header>etag</header>
<header>location</header>
<header>x-ratelimit-remaining</header>
<header>x-ratelimit-reset</header>
<header>x-request-id</header>
</expose-headers>
</cors>
GitHub is a solid example here. It exposes a long list of operationally useful headers:
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That’s not accidental. If clients need headers, expose them deliberately.
Mistake 6: Trying to “fix” CORS in the backend while APIM sits in front
If APIM is the public entry point, the browser only cares about the response from APIM.
I’ve seen teams carefully set CORS headers in App Service, Functions, or a container backend, while APIM strips, overrides, or replaces them.
Fix
Treat APIM as the CORS enforcement point when it fronts browser traffic.
That means:
- configure CORS in APIM
- don’t rely on backend CORS behavior unless APIM is passing it through intentionally
- avoid duplicate CORS logic in both places unless you really know why you need it
If both layers emit conflicting headers, debugging gets ugly fast.
Mistake 7: Allowing every origin “just for now”
This usually starts during development:
<origin>*</origin>
Then nobody comes back to lock it down.
For public read-only APIs, maybe that’s acceptable. For anything with credentials, tenant data, or admin behavior, it’s sloppy.
Fix
Be explicit about trusted origins per environment.
For example:
https://app-dev.example.comhttps://app-staging.example.comhttps://app.example.com
In APIM:
<cors allow-credentials="true">
<allowed-origins>
<origin>https://app-dev.example.com</origin>
<origin>https://app-staging.example.com</origin>
<origin>https://app.example.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>POST</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>authorization</header>
<header>content-type</header>
</allowed-headers>
</cors>
CORS is not auth, but wide-open origin policies still increase exposure and make mistakes easier.
Mistake 8: Ignoring policy order and inherited policies
APIM policy execution order matters. Inherited policies matter too.
You can have a perfectly valid cors policy, but another inherited policy changes request handling before or after it in ways that break what you expected.
Fix
Check the effective policy, not just the snippet you edited.
I always verify:
- where the policy is defined
- whether
<base />is inheriting parent policies - whether another scope overrides behavior
- what headers actually leave APIM
This sounds obvious, but APIM policy inheritance is one of those things that burns people who are otherwise very competent.
Official docs: Set or edit Azure API Management policies
Mistake 9: Treating CORS as your security boundary
CORS controls which browser origins can read responses. That’s all.
It does not replace:
- authentication
- authorization
- CSRF protections
- security headers
- backend validation
If you’re protecting a web app, CORS should sit alongside proper security controls, not pretend to be one.
For broader browser hardening like CSP and related headers, see https://csp-guide.com.
A practical APIM CORS baseline
If I were setting up a browser-facing API in APIM today, I’d start with something like this:
<policies>
<inbound>
<base />
<cors allow-credentials="true">
<allowed-origins>
<origin>https://app.example.com</origin>
<origin>https://admin.example.com</origin>
</allowed-origins>
<allowed-methods preflight-result-max-age="300">
<method>GET</method>
<method>POST</method>
<method>PUT</method>
<method>DELETE</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>authorization</header>
<header>content-type</header>
<header>x-request-id</header>
</allowed-headers>
<expose-headers>
<header>etag</header>
<header>location</header>
<header>x-request-id</header>
<header>x-ratelimit-remaining</header>
<header>x-ratelimit-reset</header>
</expose-headers>
</cors>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
Not fancy. That’s the point.
Final debugging checklist
When APIM CORS breaks, I check these in order:
- Does the browser send an
Originheader? - Is this a preflighted request?
- Does APIM allow the exact origin?
- Does APIM allow the exact method?
- Does APIM allow every requested header?
- Are credentials enabled, and if so, are you avoiding
*? - Are required response headers listed in
expose-headers? - Is the policy applied at the scope you think it is?
- Is APIM overriding what the backend sends?
Most CORS bugs in Azure API Management come down to one of those. Not all browser errors are descriptive, but the underlying failures are usually pretty boring once you inspect the actual headers.