CORS, JSON options and API conventions
CORS configured once and safely, JSON options set in one place, and output caching that respects the Vary header.
CORS in the pipeline
builder.Services.AddCors(options =>
{
options.AddPolicy("browser-apps", policy => policy
.WithOrigins("https://app.example.com", "https://admin.example.com")
.WithMethods("GET", "POST", "PATCH", "DELETE")
.WithHeaders("Content-Type", "Authorization", "Idempotency-Key")
.WithExposedHeaders("ETag", "Location", "RateLimit-Remaining")
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)));
// A public, credential-free policy for a wide audience
options.AddPolicy("public-read", policy => policy
.AllowAnyOrigin()
.WithMethods("GET")
.WithHeaders("Accept", "Content-Type"));
});
var app = builder.Build();
app.UseRouting();
app.UseCors(); // must be after UseRouting and before auth
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireCors("browser-apps");AllowAnyOrigincombined withAllowCredentialsis invalid and is rejected at startup, which is a good thing to have fail loudly.WithExposedHeadersis required before a browser script can read a custom response header such asETag; by default only the simple headers are visible.- Preflight requests are answered by the CORS middleware and never reach the endpoint, so a preflight failure is not a routing problem.
- CORS must be applied per endpoint or globally, and it must run before any middleware that short-circuits the request, or the preflight response will miss the headers.
JSON options and output caching
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
options.SerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
// Source generation: no reflection at runtime
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
// Output caching is a server-side policy: it does not need the client to opt in
builder.Services.AddOutputCache(options =>
{
// Never cache a response that depends on the caller
options.AddBasePolicy(b => b
.With(c => c.HttpContext.Request.Method == "GET")
.With(c => c.HttpContext.User.Identity?.IsAuthenticated != true)
.SetVaryByHeader("Accept")
.Expire(TimeSpan.FromSeconds(60)));
options.AddPolicy("by-id", b => b
.SetVaryByRouteValue("id")
.Tag("articles")
.Expire(TimeSpan.FromMinutes(5)));
});
app.UseOutputCache();
app.MapGet("/articles/{id:int}", GetArticle).CacheOutput("by-id");
// Invalidate the whole tag after a write
app.MapPost("/articles", async (IOutputCacheStore store, CancellationToken ct) =>
{
await store.EvictByTagAsync("articles", ct);
return Results.Created();
});| Concern | Where to set it | Note |
|---|---|---|
| Property naming | ConfigureHttpJsonOptions | Minimal APIs use this, controllers use AddControllers().AddJsonOptions |
| Enum representation | Add a converter | Strings are far friendlier for clients |
| Caching policy | Output cache policy or ResponseCache | The output cache can vary by query, header, route and user |
| Inconsistent error shape | Problem details customisation | Set it once rather than per endpoint |
| Response envelope | A convention or a result filter | Pick one and apply it everywhere or nowhere |
⚠️
Never cache an authenticated response without varying on the credential. If a policy omits the user from the cache key, one caller's response is served to another, which is a data disclosure rather than a caching bug.
FAQ
Why does my API work in curl but fail in the browser?
CORS, almost always. curl does not enforce it, and the browser does. Look for the preflight OPTIONS request and confirm the response carries the origin and the allowed headers.
Do minimal APIs and controllers share JSON options?
No. Minimal APIs read ConfigureHttpJsonOptions, controllers read AddJsonOptions. Set both, or you will get different casing on different routes.
Related
Static files, uploads and streaming responses Authentication and authorisation
Last refreshed 2026-09-18.