CORS
Let named browser origins call your API with CORS policies, read preflights correctly, and avoid the credentials and wildcard traps.
builder.Services.AddCors(o => o.AddPolicy("storefront", p => p
.WithOrigins("https://shop.example.com").AllowCredentials()
.WithHeaders("Content-Type", "Authorization").WithMethods("GET", "PUT")));
app.UseCors();
api.RequireCors("storefront");Browsers stop a script on https://shop.example.com from reading responses from https://api.example.com, because the origins (scheme, host and port) differ. CORS is how the API tells the browser which other origins may read its responses. It is enforced by the browser alone. curl, Postman, server-to-server calls and mobile apps ignore it completely, so CORS is never access control.
Simple requests and preflights
A GET, or a POST with a form content type and no custom headers, is a simple request: the browser sends it immediately with an Origin header, the server runs the handler, and the browser hides the response from the script if Access-Control-Allow-Origin does not match. The side effect already happened. Anything else, including Content-Type: application/json, an Authorization header, PUT, PATCH and DELETE, makes the browser send an OPTIONS preflight with Access-Control-Request-Method and Access-Control-Request-Headers first, and the real request only follows if the answer allows it.
ASP.NET Core answers a preflight with 204 even for an origin it rejects; it simply omits the Access-Control-* headers, and the browser blocks. For an allowed origin it lists the policy's allowed headers, and the browser compares them with what it asked for. That is why the server log shows a successful OPTIONS while the browser console shows a CORS error.
Policies
Register policies with AddCors, call app.UseCors() after routing and before authentication, and attach a named policy where it applies: RequireCors("storefront") on a route group or endpoint, [EnableCors("storefront")] on controllers, [DisableCors] to exclude one. A default policy (AddDefaultPolicy) applies to every endpoint, which is rarely what you want for internal routes. The authorization middleware lets preflights through, but your own middleware placed before UseCors, such as an API key or tenant check, will reject a preflight, because browsers never send credentials on it.
| Policy | Result |
|---|---|
WithOrigins(...) + AllowCredentials() | Correct for cookie-based browser apps; origin is echoed back |
AllowAnyOrigin() without credentials | Fine for a public, read-only, token-free API |
AllowAnyOrigin() + AllowCredentials() | InvalidOperationException when the policy is built, which with UseCors in the pipeline happens at start-up (app.Run/StartAsync fails) |
SetIsOriginAllowed(_ => true) + AllowCredentials() | Compiles and works, and lets any site make authenticated calls as your user |
Origins match exactly after lower-casing: https://shop.example.com/ with a trailing slash never matches. Subdomains need WithOrigins("https://*.example.com") plus SetIsOriginAllowedToAllowWildcardSubdomains(). A response varies by origin, so the middleware adds Vary: Origin for caches. Scripts can read only the safelisted response headers unless you add WithExposedHeaders("Location", "ETag"). SetPreflightMaxAge lets the browser cache a preflight, and browsers cap it (Chromium at two hours).
Production failure modes
- Origins hard-coded for production, so staging front ends fail; read them from configuration per environment.
- Treating CORS as protection for a cookie-authenticated form POST. A simple request is never preflighted; that is the job of SameSite cookies and antiforgery.
- A CDN caching a response without
Vary: Originand serving one origin's headers to another.
Example
When to use it
- Allow the React storefront on https://shop.example.com to PUT /orders/{id} with a bearer token while every other origin is blocked by the browser.
- Expose the Location and ETag headers so the checkout script can follow a 201 Created and send If-Match on the next update.
- Give staging front ends their own allowed origins through Cors__StorefrontOrigins__0 without changing code.
More examples
Origins from configuration per environment
Each environment has its own front-end hosts, so the list belongs in configuration, not in code. Failing at start-up on a trailing slash turns a silent mismatch, where every browser call fails with a vague console error, into an obvious deploy failure. Exposing <code>Retry-After</code> lets the storefront read the rate limiter's back-off, which scripts otherwise cannot see.
var builder = WebApplication.CreateBuilder(args);
// appsettings.Production.json: "Cors": { "StorefrontOrigins": [ "https://shop.example.com" ] }
// Staging overrides it with Cors__StorefrontOrigins__0=https://shop.staging.example.com
var storefrontOrigins = builder.Configuration
.GetSection("Cors:StorefrontOrigins")
.Get<string[]>() ?? [];
if (storefrontOrigins.Any(origin => origin.EndsWith('/')))
{
throw new InvalidOperationException("CORS origins must not end with '/', they would never match.");
}
builder.Services.AddCors(options =>
{
options.AddPolicy("storefront", policy => policy
.WithOrigins(storefrontOrigins)
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization", "Idempotency-Key")
.WithExposedHeaders("Location", "ETag", "Retry-After")
.AllowCredentials());
});
var app = builder.Build();
app.UseCors();
var orders = app.MapGroup("/orders").RequireCors("storefront");
orders.MapGet("/", () => TypedResults.Ok(Array.Empty<object>()));
// Internal endpoints get no CORS policy: browsers on other origins cannot read them.
app.MapGet("/internal/reconciliation", () => TypedResults.Ok(new { pending = 3 }));
app.Run();Reflecting every origin with credentials, and the fix
<code>AllowAnyOrigin().AllowCredentials()</code> is rejected by the framework, so developers work around it with <code>SetIsOriginAllowed(_ => true)</code>, which produces exactly the response the CORS rules were written to forbid. The wildcard subdomain form matches <code>https://eu.shop.example.com</code> but not <code>https://example.com</code> or <code>https://evilexample.com</code>. Any subdomain you allow must be one you control; a forgotten marketing site with an XSS bug becomes a way into the API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
// WRONG: echoes any Origin back with Allow-Credentials: true, so a page on
// any site can call the API with the user's cookies and read the answers.
options.AddPolicy("too-open", policy => policy
.SetIsOriginAllowed(_ => true)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
// RIGHT: your own subdomains only, over HTTPS.
options.AddPolicy("company-sites", policy => policy
.WithOrigins("https://*.example.com")
.SetIsOriginAllowedToAllowWildcardSubdomains()
.WithMethods("GET", "POST")
.WithHeaders("Content-Type")
.AllowCredentials());
});
var app = builder.Build();
app.UseCors();
app.MapGet("/customers/me", () => TypedResults.Ok(new { id = "c-1042", email = "[email protected]" }))
.RequireCors("company-sites");
app.Run();Controllers: [EnableCors] and [DisableCors]
The controller-level <code>[EnableCors]</code> applies to every action, and <code>[DisableCors]</code> removes it from one. Without <code>AllowCredentials</code> the response omits <code>Access-Control-Allow-Credentials</code>, so the browser fails any credentialed preflight and hides credentialed responses from the script; a bearer-token storefront does not need cookies anyway. <code>[DisableCors]</code> is not security: a back-office tool or curl still reaches <code>ChangePrice</code>, so it still needs an authorization policy.
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddCors(options =>
{
options.AddPolicy("storefront", policy => policy
.WithOrigins("https://shop.example.com")
.WithMethods("GET", "POST")
.WithHeaders("Content-Type", "Authorization"));
});
var app = builder.Build();
app.UseCors();
app.MapControllers();
app.Run();
public record ProductDto(int Id, string Name, decimal Price);
[ApiController]
[Route("api/products")]
[EnableCors("storefront")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public ActionResult<ProductDto> Get(int id) => new ProductDto(id, "Espresso machine", 349.00m);
// Price changes come from the back office only, never from a browser on another origin.
[HttpPost("{id:int}/price")]
[DisableCors]
public IActionResult ChangePrice(int id, [FromBody] decimal price) => NoContent();
}
Discussion