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");
  • AllowAnyOrigin combined with AllowCredentials is invalid and is rejected at startup, which is a good thing to have fail loudly.
  • WithExposedHeaders is required before a browser script can read a custom response header such as ETag; 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();
});
ConcernWhere to set itNote
Property namingConfigureHttpJsonOptionsMinimal APIs use this, controllers use AddControllers().AddJsonOptions
Enum representationAdd a converterStrings are far friendlier for clients
Caching policyOutput cache policy or ResponseCacheThe output cache can vary by query, header, route and user
Inconsistent error shapeProblem details customisationSet it once rather than per endpoint
Response envelopeA convention or a result filterPick 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.

Static files, uploads and streaming responses Authentication and authorisation

Last refreshed 2026-09-18.