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 SPAhttps://api.example.com— Azure Front Door endpoint- Origin behind Front Door:
- Azure App Service API
- Authentication:
- Bearer token in
Authorization
- Bearer token in
- Browser requests:
GET /profilePOST /orders- occasional
PUTandDELETE
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:
POSTContent-Type: application/jsonAuthorization
…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:
- What the browser sent in preflight
- What Front Door returned
- 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
OPTIONSproperly 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, andVary: 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.