A lot of teams assume Azure Front Door will “handle CORS” because it sits in front of everything. That assumption burns time.

I’ve seen this play out the same way more than once: the API works in Postman, works from curl, even works when you hit the backend directly — but the browser says no. Then someone starts adding random Access-Control-* headers at Front Door, somebody else enables caching, and suddenly the failures become intermittent. That’s when the real fun starts.

Here’s a real-world style case study of fixing CORS for an app behind Azure Front Door, with the ugly version first.

The setup

A team had this architecture:

  • https://app.example.com — React SPA
  • https://api.example.com — Azure Front Door endpoint
  • Origin behind Front Door:
    • Azure App Service API
  • Authentication:
    • Bearer token in Authorization
  • Browser requests:
    • GET /profile
    • POST /orders
    • occasional PUT and DELETE

They moved the API behind Azure Front Door for WAF, routing, and global performance. Immediately, the frontend started failing on authenticated requests.

The browser error looked familiar:

Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Classic.

What was happening

The frontend sent a request like this:

await fetch("https://api.example.com/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`
  },
  body: JSON.stringify({ sku: "ABC-123", quantity: 1 })
});

Because it used:

  • POST
  • Content-Type: application/json
  • Authorization

…the browser sent a preflight OPTIONS request first.

That preflight needed a response something like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin

Instead, Azure Front Door forwarded OPTIONS to the origin, and the origin returned a generic 405 or a 200 without CORS headers. Browser blocked it. Postman didn’t care. That difference is where people lose half a day.

The “before” configuration

Their backend had partial CORS support, but only on normal API routes. Preflight wasn’t consistently handled. Front Door also had a response header rule that looked like this:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: *

That looks convenient. It was also wrong for their use case.

Why?

1. Wildcard origin with credentials is a dead end

If the frontend ever needs cookies or credentialed requests, Access-Control-Allow-Origin: * won’t work. Browsers reject that combination.

Even if you use bearer tokens instead of cookies, wildcard origin is usually too broad for a private app.

2. Front Door was masking origin behavior

Some responses got CORS headers added by Front Door, some didn’t, especially for error responses and preflight. That created inconsistent behavior by route.

3. Caching made it worse

A preflight or API response without Vary: Origin can be cached and reused incorrectly across origins. If you support more than one frontend environment — production, staging, local dev — this gets messy fast.

The debugging path that actually worked

The team stopped guessing and inspected real responses end to end.

I usually check three things:

  1. What the browser sent in preflight
  2. What Front Door returned
  3. What the origin returned when bypassing Front Door

A header inspection tool helps here. HeaderTest is handy for quickly seeing what headers are actually making it to the client, especially when you suspect a proxy is rewriting or stripping them.

They also reproduced the preflight manually:

curl -i -X OPTIONS https://api.example.com/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type"

The result was basically:

HTTP/1.1 405 Method Not Allowed
Allow: GET, POST

No CORS headers. Browser had no chance.

The fix

The clean fix was to make the origin own CORS completely, and use Azure Front Door only for routing, not for inventing CORS policy on top.

That meant:

  • handle OPTIONS properly at the API
  • return origin-specific Access-Control-Allow-Origin
  • include Vary: Origin
  • explicitly allow needed headers and methods
  • avoid wildcard policy for a private application

After: backend CORS config

Here’s a practical Express example that mirrors the fix:

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://staging.example.com",
  "http://localhost:5173"
]);

app.use(cors({
  origin(origin, callback) {
    // allow non-browser requests with no Origin header
    if (!origin) return callback(null, true);

    if (allowedOrigins.has(origin)) {
      return callback(null, true);
    }

    return callback(new Error("Origin not allowed by CORS"));
  },
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowedHeaders: ["Authorization", "Content-Type"],
  exposedHeaders: ["ETag", "Location"],
  maxAge: 86400
}));

app.options("*", cors());

app.use(express.json());

app.post("/orders", (req, res) => {
  res.status(201).json({ ok: true });
});

app.listen(3000);

That produced responses like:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Authorization,Content-Type
Access-Control-Expose-Headers: ETag,Location
Vary: Origin

That’s the right shape.

Azure Front Door changes

They removed the “set CORS headers everywhere” rule from Front Door.

That part matters. If both Front Door and the origin inject CORS headers, you can end up with:

  • duplicate headers
  • conflicting origins
  • broken preflight on one path and not another

Front Door stayed responsible for:

  • TLS
  • WAF
  • routing
  • caching of safe public content

Not CORS policy.

If you absolutely must use Front Door rules for CORS, keep it narrow and deterministic. Don’t slap Access-Control-Allow-Origin: * on every response and hope for the best.

Before and after behavior

Before

Frontend request:

fetch("https://api.example.com/orders", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`
  },
  body: JSON.stringify({ sku: "ABC-123" })
});

Preflight response:

HTTP/1.1 405 Method Not Allowed

Browser result:

Blocked by CORS policy

After

Same frontend code, no app changes needed.

Preflight response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin

Actual response:

HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: ETag, Location
Vary: Origin
Content-Type: application/json

Browser result: success.

A useful real-world header reference

If you want a good example of exposed headers in the wild, GitHub’s API is a solid one. 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 there for a reason. Browsers don’t let frontend JavaScript read arbitrary response headers by default. If your app needs ETag, pagination links, rate-limit values, or Location after a create call, you need to expose them explicitly.

I see teams forget this all the time. The request works, but the frontend can’t read the header it needs, so someone thinks auth is broken. It’s not auth. It’s missing Access-Control-Expose-Headers.

The Azure Front Door gotchas that matter

Don’t cache CORS responses blindly

If your API reflects the incoming Origin, send:

Vary: Origin

Without it, a CDN or proxy can serve a response generated for one origin to another origin. That causes weird browser failures that look random.

Don’t use wildcard headers unless you truly mean it

Some platforms accept Access-Control-Allow-Headers: *, but browser support and behavior around wildcards can trip you up, especially with credentialed or older clients. Explicit is better.

Error responses need CORS too

Your API might return proper CORS headers on 200 OK, then forget them on 401, 403, or 500. From the browser’s point of view, that turns a real API error into a vague CORS failure.

Make sure your middleware or platform adds CORS headers consistently, including on failures.

CORS is not access control

I still have to say this because people misuse it constantly: CORS is a browser enforcement mechanism, not an auth layer. It does not protect your API from direct requests. Your actual security still comes from auth, session handling, token validation, WAF, rate limiting, and the rest of your HTTP hardening. If you’re reviewing broader response-header security, https://csp-guide.com is a useful reference for the non-CORS side of that work.

The pattern I recommend

For Azure Front Door setups, my default advice is:

  • define CORS at the origin application or API gateway
  • let Front Door pass it through
  • only use Front Door header rewriting for edge cases
  • test preflight explicitly with curl
  • verify OPTIONS, error responses, and Vary: Origin

That approach is boring, which is exactly why it works.

When CORS breaks behind Azure Front Door, the problem usually isn’t Front Door itself. It’s the split-brain setup where the edge, the app, and sometimes the framework all think they own CORS. Pick one place to define policy. For most teams, that place should be the origin.

That’s the fix that tends to survive the next migration, the next environment, and the next person who touches the config.