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:

  • OPTIONS
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

then APIM needs to answer correctly before the real request is even attempted.

Common symptoms

  • GET works, POST fails
  • Requests with Authorization fail
  • Requests with Content-Type: application/json fail
  • 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:

  • ETag
  • Location
  • 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.com
  • https://app-staging.example.com
  • https://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:

  1. Does the browser send an Origin header?
  2. Is this a preflighted request?
  3. Does APIM allow the exact origin?
  4. Does APIM allow the exact method?
  5. Does APIM allow every requested header?
  6. Are credentials enabled, and if so, are you avoiding *?
  7. Are required response headers listed in expose-headers?
  8. Is the policy applied at the scope you think it is?
  9. 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.