CORS done right: a practical guide
I've seen production outages caused by misunderstanding CORS—developers treating it as an API security layer when it's actually a browser-level constraint. CORS done right means understanding that it protects browsers, not APIs, and configuring it with the same rigor you'd apply to authentication. Over my 5+ years building platforms across e-commerce, SaaS, and trading systems, I've learned that a single AllowAnyOrigin call can create as many problems as it solves.
In this article, I'll walk you through configuring CORS in ASP.NET Core the way I do it in production—with explicit origins, layered policies, and proper caching. By the end, you'll know exactly when CORS matters and when it doesn't.
Understanding what CORS actually protects
CORS (Cross-Origin Resource Sharing) is a browser security feature. When your frontend JavaScript runs in a browser and tries to fetch data from a different origin, the browser enforces CORS rules. A server-to-server call, a mobile app, or a desktop client completely bypasses CORS—the browser isn't involved.
This is critical: CORS is not authentication, not authorization, not encryption. It doesn't replace tokens, API keys, or HTTPS. It simply tells the browser whether a cross-origin request is allowed. Confusing it with security is one of the most expensive mistakes I've seen.
Let me give you a real scenario. I once inherited a travel platform where the previous team had enabled AllowAnyOrigin with credentials. They believed this meant "the API is secure." In reality, any malicious website could make authenticated requests on behalf of a user, as long as the user visited that site in the same browser session. Security came entirely from their token validation, not from CORS.
Name explicit origins—never AllowAnyOrigin with credentials
The golden rule: if you're accepting credentials (cookies, auth headers), you must name explicit origins. You cannot use AllowAnyOrigin because the browser will reject the response—it sees the wildcard as incompatible with credentials.
Here's how I do it in production:
builder.Services.AddCors(options =>
{
options.AddPolicy("PublicApi", policy =>
{
var allowedOrigins = new[]
{
"https://example.com",
"https://app.example.com",
"https://mobile-web.example.com"
};
policy
.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
app.UseCors("PublicApi");
Notice I'm naming the exact origins my frontend runs on. No wildcards. No guessing. If I deploy a new subdomain, I update this list. This is maintainable because:
- It's explicit—future maintainers see exactly what's allowed
- It's reviewable—CORS changes go through code review like any security boundary
- It's environment-aware—I use configuration for different envs
If you're building a third-party API where strangers integrate with you, you might use AllowAnyOrigin. But then do not allow credentials:
options.AddPolicy("ThirdPartyApi", policy =>
{
policy
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
// Note: no AllowCredentials()
});
This signals to the browser that unauthenticated requests are fine, but authenticated ones aren't. The security comes from your token validation, not CORS.
Separate policies for public and admin APIs
I rarely use a single CORS policy for an entire application. Different endpoints have different audiences, and their CORS rules should reflect that.
Consider an e-commerce platform I built. The public storefront frontend can access the product catalog, cart, and payment endpoints from shop.example.com. The admin dashboard, used internally by staff, runs on admin.example.com. The analytics backend, used by internal tools and third-party dashboards, has yet another set of origins. Mixing these policies is chaos.
builder.Services.AddCors(options =>
{
// Public storefront
options.AddPolicy("StorefrontPolicy", policy =>
{
policy
.WithOrigins("https://shop.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
// Admin dashboard
options.AddPolicy("AdminPolicy", policy =>
{
policy
.WithOrigins("https://admin.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
// Analytics (no credentials needed)
options.AddPolicy("AnalyticsPolicy", policy =>
{
policy
.WithOrigins("https://analytics-partner.com")
.AllowAnyMethod()
.AllowAnyHeader();
});
});
app.MapGroup("/api/products")
.WithName("Products")
.RequireCors("StorefrontPolicy")
.MapProductEndpoints();
app.MapGroup("/api/admin")
.WithName("Admin")
.RequireCors("AdminPolicy")
.MapAdminEndpoints();
app.MapGroup("/api/analytics")
.WithName("Analytics")
.RequireCors("AnalyticsPolicy")
.MapAnalyticsEndpoints();
This approach scales. Each policy is independently testable and reviewable. When you need to onboard a new analytics partner, you update the analytics policy without touching storefront logic. When an admin domain changes, it's a one-line change in the admin policy.
Preflight caching with SetPreflightMaxAge
CORS preflight requests happen before every cross-origin request that uses methods like PUT, DELETE, or custom headers. The browser sends an OPTIONS request to check if the actual request is allowed.
These preflight requests are a performance cost. I've measured this on mobile networks—an extra 100ms roundtrip per request adds up. This is where SetPreflightMaxAge comes in:
options.AddPolicy("PublicApi", policy =>
{
policy
.WithOrigins("https://example.com", "https://app.example.com")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromSeconds(3600));
});
This tells the browser to cache the preflight response for an hour. On the first PUT request, the browser asks permission. On the next 50 PUT requests in that hour, it doesn't ask—it uses the cached response. The performance difference is real, especially for data-heavy operations.
Set this based on how often you change your API's allowed methods and headers:
- Stable internal APIs: 86400 seconds (24 hours)
- Public APIs with occasional changes: 3600 seconds (1 hour)
- Rapidly evolving APIs: 600 seconds (10 minutes) or skip it
I once worked on a trading platform where we skipped this—preflights were firing thousands of times per second under load. Adding a reasonable max age cut our OPTIONS request volume by 90% without sacrificing flexibility.
Server-to-server calls ignore CORS entirely
This is the part many juniors don't grasp: CORS only matters when JavaScript in a browser makes the request. Your backend service calling another backend service? CORS doesn't apply. Your mobile app calling your API? CORS doesn't apply. Your cron job? CORS doesn't apply.
I mention this because I've seen teams add AllowAnyOrigin for legitimate server-to-server integrations, not realizing they were already bypassing CORS. The result? An unnecessarily permissive API.
If a partner's backend needs to call your API, handle it with:
- API keys in headers
- OAuth 2.0 client credentials flow
- mTLS (mutual TLS) for high-security scenarios
- IP whitelisting for trusted partners
CORS doesn't enter the picture. The browser isn't involved. You're securing server-to-server communication the way you'd secure any external integration.
Common mistakes
AllowAnyOrigin with AllowCredentials: This doesn't work. The browser will reject the response. If you need credentials, you must name explicit origins.
Using CORS as your security boundary: I've seen teams say "our API is secure because we have CORS." No. CORS is a convenience layer that prevents accidentally exposing user data to malicious sites. Real security comes from authentication (who are you?), authorization (what can you do?), and encryption (does anyone eavesdrop?). CORS adds none of those.
Setting AllowAnyHeader with credentials: This works technically but is overly permissive. You're saying "any header is fine." Better to be explicit: .AllowAnyHeader() is fine for public APIs, but for authenticated APIs, consider naming specific headers you actually need.
Not testing CORS in staging: I caught a CORS misconfiguration in production once because the staging environment had a looser policy (AllowAnyOrigin). Testing with the real CORS setup catches these issues early.
Forgetting CORS on API gateways: If you're using a gateway (Kong, AWS API Gateway, reverse proxy), CORS might be set there instead of your backend. Misconfiguring one while thinking the other handles it is a common trap. Know your architecture.
Key takeaways
- CORS is a browser feature for protecting browser-based frontends, not an API security mechanism
- Name explicit origins when accepting credentials; never use AllowAnyOrigin with AllowCredentials
- Create separate CORS policies for different endpoint groups (public, admin, third-party)
- Set SetPreflightMaxAge to reduce preflight requests; aim for 1 hour for stable APIs
- Server-to-server calls bypass CORS entirely; secure them with API keys, OAuth, or IP whitelisting instead
- CORS misconfiguration is about convenience, not security; rely on authentication and authorization for actual protection
- Test CORS rules in staging with the production configuration






Comments
Loading comments…