Skip to content

ASP.NET Core

Targets: net10.0, csharp-14 · Last reviewed: 2026-09-14 · Sources: dotnet-blog, aspnet-blog, ms-learn, meziantou, andrew-lock, milan-jovanovic

Target ASP.NET Core 10. Minimal APIs are the default for new HTTP APIs.

Opinions

Hosting and deployment

Run Kestrel directly, packaged as a container image built by dotnet publish /t:PublishContainer with no Dockerfile. Kestrel as the edge server is a fully supported configuration; when you do sit behind a proxy or ingress (for port sharing, TLS termination, or load balancing), enable host filtering and forwarded-headers handling. That is the proxy's contract, not optional hardening. The SDK's container publish builds an OCI image from MSBuild properties (ContainerRepository, ContainerRegistry), keeps the base image patched with the SDK, and needs no daemon to produce a tarball for scanning pipelines. Hand-written Dockerfiles drift; use them only when you need custom image layers. (Microsoft Learn: When to use a reverse proxy with Kestrel, Microsoft Learn: Containerize an app with dotnet publish)

Caching with HybridCache

Use HybridCache instead of hand-rolled cache-aside over IMemoryCache/IDistributedCache. GetOrCreateAsync() handles stampede protection, L1 (in-memory) + L2 (distributed) tiering, and tag-based invalidation. Package: Microsoft.Extensions.Caching.Hybrid.

builder.Services.AddHybridCache();
app.MapGet("/api/orders/{id}", async (string id, HybridCache cache, CancellationToken ct) =>
    await cache.GetOrCreateAsync(
        $"order:{id}",
        async token => await LoadOrderAsync(id, token),
        cancellationToken: ct));

One factory call per key under concurrency, and a distributed backend (Redis, Postgres) plugs in via the existing IDistributedCache registration without touching call sites. (.NET Blog: Hello HybridCache! Streamlining Cache Management for ASP.NET Core Applications, .NET Blog: High-Performance Distributed Caching with .NET and Postgres on Azure)

Request validation

Use minimal-API validation (AddValidation() + data annotations) for request validation. Invalid requests return 400 with problem details automatically, and the validation APIs live in Microsoft.Extensions.Validation, reusable outside HTTP.

builder.Services.AddValidation();
app.MapPost("/api/orders", (CreateOrder order) =>
    TypedResults.Created($"/api/orders/1", order));

record CreateOrder(
    [property: Required] string Sku,
    [property: Range(1, 100)] int Quantity);

Opt individual endpoints out with .DisableValidation() rather than opting the app in piecemeal. (Microsoft Learn: What's new in ASP.NET Core 10)

OpenAPI and API versioning

Generate the OpenAPI document at build time from Microsoft's own package. OpenAPI 3.1 generation comes from Microsoft's own Microsoft.AspNetCore.OpenApi, which the Web API template references and calls for you; set <GenerateDocumentationFile>true</GenerateDocumentationFile> so the source generator populates XML doc comments into the document. Name your handlers, because lambdas lose their comments. Version APIs with Asp.Versioning v10 (Asp.Versioning.Http, Asp.Versioning.Mvc.ApiExplorer, Asp.Versioning.OpenApi) and WithDocumentPerVersion() for one document per version:

builder.Services.AddOpenApi();
builder.Services
    .AddApiVersioning()
    .AddApiExplorer(options => options.GroupNameFormat = "'v'VVV")
    .AddOpenApi(); // the Asp.Versioning.OpenApi overload

var app = builder.Build();

app.MapOpenApi().WithDocumentPerVersion(); // /openapi/v1.json, /openapi/v2.json, ...

var orders = app.NewVersionedApi("Orders");
var v1 = orders.MapGroup("/api/orders").HasApiVersion(1.0);

(.NET Blog: Combining API versioning with OpenAPI in .NET 10 applications, Microsoft Learn: What's new in ASP.NET Core 10)

Typed clients

Generate typed clients from your own OpenAPI document at build time (Kiota) rather than hand-writing HttpClient wrappers. The contract stays versioned and the client cannot drift. (Meziantou: Kiota client at build time)

HTTP caching defaults

APIs send Cache-Control: no-cache, no-store, must-revalidate by default; opt individual endpoints into caching deliberately. Stale-data bugs and cache-poisoning surprises come from the opposite default. (Meziantou: Disable HTTP caching by default)

Server push

Use Server-Sent Events (TypedResults.ServerSentEvents) for one-way server push. (Microsoft Learn: What's new in ASP.NET Core 10)

Authentication: passkeys first

Use ASP.NET Core Identity's built-in passkey (WebAuthn/FIDO2) support for user sign-in instead of passwords or a third-party FIDO library. Passkey management and login ship in Identity and the Blazor Web App template in .NET 10. They resist phishing, leave nothing server-side to leak, and add no dependency to vet. Keep a second factor or recovery path for account recovery, but new apps should not be growing a password table in 2026. (Microsoft Learn: What's new in ASP.NET Core 10, Microsoft Learn: Passkeys in ASP.NET Core)

Return 401/403 from API endpoints, never login redirects: ASP.NET Core 10 avoids cookie redirects for known API endpoints; align custom auth handlers with that. (Microsoft Learn: What's new in ASP.NET Core 10)

Request timeouts

Give every endpoint a deadline, and flow the cancellation token into the work it starts. ASP.NET Core applies no application timeout of its own, so a stalled query or dependency keeps consuming resources long after the response stops being useful, and whatever sits in front of you ends up owning both the deadline and the response body. AddRequestTimeouts only registers the services; the limit comes from a named policy or WithRequestTimeout on the endpoint. The middleware is cooperative, which is the part that catches people out: it cancels HttpContext.RequestAborted and then waits. A handler that never passes the token on runs to completion and returns 200, and no 504 appears, because nothing threw. So a 504 tells you cancellation reached the middleware, not that the database stopped working. Give reads and exports separate policies rather than one global number, and opt streaming responses out with DisableRequestTimeout(), since a response that has already begun cannot be replaced with a clean 504. (Microsoft Learn: Request timeouts middleware, Jovanović: Your ASP.NET Core endpoints don't have a timeout)

builder.Services.AddRequestTimeouts(options =>
{
    options.AddPolicy("api-read", TimeSpan.FromSeconds(3));
    options.AddPolicy("report-export", TimeSpan.FromSeconds(30));
});

app.UseRequestTimeouts();

// The token reaches EF Core, so the timeout actually stops the query.
app.MapGet("/orders/{id:guid}", async (Guid id, AppDbContext db, CancellationToken cancellationToken) =>
        await db.Orders.AsNoTracking().SingleOrDefaultAsync(o => o.Id == id, cancellationToken))
    .WithRequestTimeout("api-read");

app.MapGet("/events", StreamEvents).DisableRequestTimeout();

Timeouts do not fire while a debugger is attached, so verify this one without.

Rate limiting

Put named Microsoft.AspNetCore.RateLimiting policies on your public endpoints, rejecting with 429. The default rejection status is 503, so always override it. (Microsoft Learn: RateLimiterOptions.RejectionStatusCode) Partition by a stable identity (authenticated user, API key tier), never by raw client-controlled input like spoofable IP headers. Use the concurrency limiter for expensive endpoints where in-flight work is the real constraint.

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddFixedWindowLimiter("api", limiter =>
    {
        limiter.PermitLimit = 100;
        limiter.Window = TimeSpan.FromMinutes(1);
    });
});

var app = builder.Build();
app.UseRateLimiter();

app.MapGet("/api/orders", () => Results.Ok()).RequireRateLimiting("api");

This is a per-node fairness and abuse-control tool, not DDoS protection, which belongs at the edge in a WAF or CDN. Load-test limits before shipping them. (Microsoft Learn: Rate limiting middleware in ASP.NET Core)

Observability: propagate trace context across async boundaries

When work crosses a process or time boundary, capture the OpenTelemetry context explicitly and restore it on the consumer side; automatic propagation only covers synchronous HTTP. Message queues, background jobs, outbox tables and scheduled work all cross one. Serialize Activity.Current's context with Propagators.DefaultTextMapPropagator and store it alongside the message; on consumption, start the new activity with the extracted ActivityContext as parent for linear flows, or attach it as an ActivityLink when many messages fan into one operation. Do not propagate Baggage by default: it bloats payloads and leaks whatever anyone upstream stuffed into it. (Meziantou: Propagating OpenTelemetry context in .NET)

CSRF defence in depth

Layer CSRF defence with Fetch Metadata headers (Sec-Fetch-Site and friends) alongside token-based antiforgery; .NET 11 will automate this. (Lock: Understanding the Fetch Metadata headers)

Localization

Register AddLocalization and run UseRequestLocalization early, driving culture from a cookie rather than Accept-Language. The browser header reflects the user's OS, not a choice they made. Provider order, the SupportedCultures/SupportedUICultures split, and resource-key policy are in globalization.md.

Coming next (preview, not yet the opinion)

.NET 11 previews add automatic Fetch-Metadata-based CSRF protection (removing UseAntiforgery() for minimal APIs/Blazor SSR). (Lock: Automatic CSRF protection based on Fetch Metadata headers)