Updated on
These are the practices we follow in every ASP.NET Core Web API we build: a short Program.cs, a layered solution, thin controllers, DTOs at the boundary, one central error handler, async code throughout, and security from day one.
We settled on these after years of building and maintaining Web APIs, and this version is updated for .NET 10. A few practices from the first version of this article are gone, because the framework now does that job for us.
But wouldn’t you agree that a project that works is not enough? Somebody else will most probably work on it after us. That somebody might even be us, six months later, wondering who wrote this.
So, what should we pay attention to?
Service Configuration in Program.cs
Since .NET 6, a new ASP.NET Core project has no Startup class. We register services on builder.Services and build the middleware pipeline on app, both in Program.cs.
The practice is the same as it was in the Startup days: keep the service registration short and readable. The registration code has to live somewhere, but it doesn’t have to live in Program.cs.
Don’t do this in a real project. It registers CORS inline, which works, but it doesn’t scale:
builder.Services.AddCors(options =>
{
options.AddPolicy("CorsPolicy", policy =>
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
Now imagine Program.cs after we register a few dozen services like this.
That’s not readable at all.
The better way is an extension method on IServiceCollection. We keep all of them in one static class, ServiceExtensions, and this is the CORS one:
public static void ConfigureCors(this IServiceCollection services, IConfiguration configuration) =>
services.AddCors(options =>
options.AddPolicy(CorsPolicy, policy => policy
.WithOrigins(configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
.AllowAnyMethod()
.AllowAnyHeader()));
CorsPolicy is a constant in the same class, so the policy name is spelled in one place only.
Notice that we also stopped allowing any origin. The allowed origins come from configuration now, so each environment lists its own. We’ll get to that in a minute, and our article on enabling CORS in ASP.NET Core covers the policy options in detail.
Program.cs then needs a single line:
builder.Services.ConfigureCors(builder.Configuration);
Here’s the whole Program.cs of our sample project once every practice in this article is in place:
using WebApiBestPractices.Api.Extensions;
using WebApiBestPractices.Api.Handlers;
using WebApiBestPractices.Application.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureCors(builder.Configuration);
builder.Services.ConfigureApplicationServices();
builder.Services.ConfigureVersioning();
builder.Services.ConfigureOutputCache();
builder.Services.ConfigureRateLimiting(builder.Configuration);
builder.Services.ConfigureJwt(builder.Configuration);
builder.Services.AddAuthorization();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters();
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors(ServiceExtensions.CorsPolicy);
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();
app.MapControllers();
// The same data through a Minimal API endpoint, for comparison.
app.MapGet("/api/minimal/owners", async (IOwnerService ownerService, CancellationToken cancellationToken) =>
TypedResults.Ok(await ownerService.GetAllAsync(cancellationToken)));
app.Run();
Each Configure... line turns on one feature, and its details live in ServiceExtensions. The order of the Use... calls matters, too. The exception handler comes first so it can catch everything after it, and output caching comes after authentication and authorization, so it never serves a cached response to the wrong user.

The order of the middleware in our Program.cs, from the first component a request meets to the controller.
To learn more about this style of service configuration, check out our article on configuring services with extension methods.
How Should We Structure an ASP.NET Core Web API Project?
We split the solution by responsibility, so each project has one reason to change and the dependencies point inward.
For a typical Web API, that’s four projects. The API project is the host: Program.cs, controllers, filters, and the exception handler. An Application project holds the services, the DTOs, and the mapping between entities and DTOs. A Domain project holds the entities and the contracts, such as repository interfaces and domain exceptions. An Infrastructure project implements those contracts against a database, a file system, or another API.
The API project references Application and Infrastructure. Application references only Domain. Domain references nothing, so the business rules never depend on a web framework or a database driver.
That layout is where we start. A small internal API can live happily in one project with a folder per concern. We split when a second team, a second host, or the test suite makes the boundaries worth their cost.
This is the structure of the sample project for this article:
WebApiBestPractices.sln ├── WebApiBestPractices.Api │ ├── Controllers │ ├── Extensions │ ├── Filters │ ├── Handlers │ ├── Logging │ ├── Security │ └── Program.cs ├── WebApiBestPractices.Application │ ├── Dtos │ ├── Mapping │ └── Services ├── WebApiBestPractices.Domain │ ├── Contracts │ ├── Entities │ └── Exceptions ├── WebApiBestPractices.Infrastructure │ └── Repositories └── WebApiBestPractices.Tests
Does every API need four projects? Not always, as we said above. But once we have them, the compiler enforces the boundaries for us, because Domain can’t reference a controller.
If you want to take this further, our step-by-step guides to Clean Architecture in .NET and Onion Architecture in .NET build the same idea out in full.
Environment-Based Settings
While we develop the application, it runs in the Development environment. Once we publish it, it runs in Production, and maybe in Staging on the way there. Each environment needs its own settings, so a separate configuration file per environment is always a good practice.
ASP.NET Core gives us this out of the box. A new project comes with appsettings.json and appsettings.Development.json, and we add appsettings.Production.json next to them. At startup, the framework loads appsettings.json first and then the file for the current environment, whose values win. The environment itself comes from the ASPNETCORE_ENVIRONMENT variable, and an app with no value set runs as Production.
In our sample, the allowed CORS origins differ per environment. appsettings.Development.json allows the local front end:
"Cors": {
"AllowedOrigins": [ "https://localhost:4200" ]
}
And appsettings.Production.json allows only the real one:
"Cors": {
"AllowedOrigins": [ "https://app.example.com" ]
}
The ConfigureCors method from the first section reads whichever list is active, and the code never changes.
Production secrets never go into any of these files. Connection strings with passwords, API keys, and JWT signing keys belong in user secrets during development and in environment variables or a vault such as Azure Key Vault in production. Our sample keeps a development-only signing key in appsettings.Development.json so it runs with no setup. Production has no key at all until we supply one, for example through a JwtSettings__SecretKey environment variable, and the app refuses to start without it.
Our articles on multiple environments in ASP.NET Core and securing sensitive data locally go deeper into both topics. Microsoft’s own reference is ASP.NET Core runtime environments.
Data Access Layer
In many examples and tutorials, we see data access code living inside the controllers, or a DbContext created by hand in every action. This is something we shouldn’t do.
The data access layer should be a separate service behind an interface. In our sample, the contract lives in the Domain project:
public interface IOwnerRepository
{
Task<IReadOnlyList<Owner>> GetAllAsync(CancellationToken cancellationToken);
Task<Owner?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<bool> EmailExistsAsync(string email, CancellationToken cancellationToken);
Task AddAsync(Owner owner, CancellationToken cancellationToken);
Task UpdateAsync(Owner owner, CancellationToken cancellationToken);
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
}
We register the implementation in the built-in dependency injection container:
services.AddSingleton<IOwnerRepository, InMemoryOwnerRepository>(); services.AddScoped<IOwnerService, OwnerService>();
The repository is a singleton only because our sample keeps the data in memory, so it runs without a database. A repository wrapping an EF Core DbContext would be scoped, the same lifetime AddDbContext gives the context.
Any class that needs the repository asks for it in its constructor. Here we use a primary constructor, which saves us the field-and-assignment boilerplate:
public class OwnerService(IOwnerRepository repository, HybridCache cache) : IOwnerService
{
public async Task<IReadOnlyList<OwnerDto>> GetAllAsync(CancellationToken cancellationToken)
{
var owners = await repository.GetAllAsync(cancellationToken);
return owners.Select(o => o.ToDto()).ToList();
}
Because OwnerService only knows the interface, we can swap the in-memory implementation for EF Core or Dapper, or for a fake in tests, without touching it. Our tests do exactly that.
The repository logic should always be based on interfaces, and making it generic gives us reuse as well. Check out the generic repository pattern in ASP.NET Core Web API to see how we implement it, and our article on dependency injection in ASP.NET Core for the service lifetimes.
Controllers
Controllers should be as clean as possible, with no business logic inside. A controller accepts its services through the constructor and maps HTTP requests to them:
[ApiController]
[ApiVersion("1.0")]
[Route("api/owners")]
[EnableRateLimiting(ServiceExtensions.PerClientRateLimit)]
public class OwnersController(
IOwnerService ownerService,
IOutputCacheStore outputCache,
ILogger<OwnersController> logger) : ControllerBase
{
[HttpGet]
[OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)]
public async Task<ActionResult<IEnumerable<OwnerDto>>> GetOwners(CancellationToken cancellationToken)
{
var owners = await ownerService.GetAllAsync(cancellationToken);
logger.OwnersReturned(owners.Count);
return Ok(owners);
}
We derive API controllers from ControllerBase, not Controller. The Controller class adds support for views, which an API never renders.
And we put [ApiController] on every API controller. It makes attribute routing a requirement, returns a 400 response automatically when the model is invalid, infers where parameters bind from, and turns error status codes into problem details responses. We’ll lean on two of those in the next section.
The other attributes on this class (versioning, rate limiting, output caching) each get their own section below.
Actions
Our actions should be clean and simple too. Their job is to accept the request, call a service, and return a response.
Here’s an action from the first version of this article. Don’t write new code like this, because most of it is now done by the framework:
[HttpPost]
public IActionResult CreateOwner([FromBody]OwnerForCreationDto owner)
{
try
{
if (owner == null)
{
return BadRequest("Owner object is null");
}
if (!ModelState.IsValid)
{
return BadRequest("Invalid model object");
}
//additional code
return CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner);
}
catch (Exception ex)
{
_logger.LogError($"Something went wrong inside the CreateOwner action: {ex}");
return StatusCode(500, "Internal server error");
}
}
And here’s the same action today:
[HttpPost]
public async Task<ActionResult<OwnerDto>> CreateOwner(
OwnerForCreationDto owner, CancellationToken cancellationToken)
{
var createdOwner = await ownerService.CreateAsync(owner, cancellationToken);
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
return CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner);
}
Where did everything go?
The null check and the ModelState check are gone because [ApiController] rejects an invalid body with a 400 before our action runs. [FromBody] is gone because [ApiController] infers it for complex types. The try-catch block is gone because the global exception handler takes care of failures, as we’ll see in the next section.
This is what the client gets for an invalid body, captured from our sample:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Email":["The Email field is not a valid e-mail address."]},"traceId":"00-0d4f81b181336fe992c007bb33b3b042-8283e54d338b0f72-00"}
The CancellationToken parameter is new as well. ASP.NET Core binds it to the request, so the work stops if the client disconnects. The cache eviction line belongs to the caching section.
We return ActionResult<T> when the action returns a body. It tells OpenAPI and the reader what the success type is, and it still lets us return NotFound() or BadRequest(). We cover the options in our article on ASP.NET Core Web API return types.
The helper methods we use most on ControllerBase are:
Okreturns the 200 status codeCreated,CreatedAtRoute,CreatedAtActionreturn the 201 status codeNoContentreturns the 204 status codeBadRequestreturns the 400 status codeUnauthorizedreturns the 401 status codeForbidreturns the 403 status codeNotFoundreturns the 404 status codeProblemreturns a problem details response with the status code we provide
Handling Errors Globally
Keeping try-catch blocks out of our actions only works if something else catches the exceptions. That something is the exception handler middleware, and since .NET 8 we plug our own logic into it with the IExceptionHandler interface.
Here’s the handler from our sample:
public class GlobalExceptionHandler(
IProblemDetailsService problemDetailsService,
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
DuplicateOwnerEmailException => (StatusCodes.Status409Conflict, "The owner already exists."),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
};
if (statusCode == StatusCodes.Status500InternalServerError)
logger.LogError(exception, "Unhandled exception for {Path}", httpContext.Request.Path);
httpContext.Response.StatusCode = statusCode;
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = statusCode == StatusCodes.Status409Conflict ? exception.Message : null
}
});
}
}
The handler maps exceptions we know about to the right status code. Our service throws DuplicateOwnerEmailException when an email is taken, and the client gets a 409 Conflict. Anything else becomes a 500 with a generic title and no exception message, because a stack trace or an internal message in a production response tells an attacker how our code works.
We write the response through IProblemDetailsService, so the errors our handler writes have the same problem details shape (RFC 9457) as the 400, 401 and 404 responses the framework writes. This is the 409 from our sample:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"The owner already exists.","status":409,"detail":"An owner with the email '[email protected]' already exists.","traceId":"00-d87c29d77fee6429ab666a2f5fa6241d-c6a81d1f7ef501bb-00"}
Then we register the handler and the problem details service:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddProblemDetails();
And we add the middleware as the first component of the pipeline:
app.UseExceptionHandler();
Calling UseExceptionHandler() with no arguments needs AddProblemDetails() (or an error path, or a fallback handler). Without one of them, the app throws at startup. If we register several handlers, they run in the order we registered them until one returns true.
One .NET 10 change affects this handler. When a handler returns true, the middleware no longer writes its own logs and metrics for that exception, which it did in .NET 8 and 9. That’s why our handler logs unexpected exceptions itself. If you want the old behavior back, set SuppressDiagnosticsCallback on ExceptionHandlerOptions.
To read more about this topic, check out global exception handling in ASP.NET Core Web API, our walkthrough of IExceptionHandler, and our article on the ProblemDetails class.
Using Action Filters to Remove Duplicated Code
Filters in ASP.NET Core run code before or after a specific stage of the request pipeline. That makes them the right place for checks we’d otherwise repeat in several actions.
In the first version of this article, our example was a model validation filter. [ApiController] made that one redundant, since it already returns a 400 with the validation errors before the action runs, as we saw in the Actions section.
So let’s take a check [ApiController] doesn’t do for us. Both our PUT and DELETE actions need to return 404 when the owner doesn’t exist. Instead of repeating that lookup in both, we write it once as a filter:
public class ValidateOwnerExistsFilter(IOwnerService ownerService) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("id", out var value) && value is Guid id)
{
var owner = await ownerService.GetByIdAsync(id, context.HttpContext.RequestAborted);
if (owner is null)
{
context.Result = new NotFoundResult();
return;
}
}
await next();
}
}
Setting context.Result short-circuits the request, so the action never runs.
The filter needs a service from the container, so we register it:
services.AddScoped<ValidateOwnerExistsFilter>();
And apply it with the generic ServiceFilter attribute:
[HttpPut("{id:guid}")]
[ServiceFilter<ValidateOwnerExistsFilter>]
public async Task<IActionResult> UpdateOwner(
Guid id, OwnerForUpdateDto owner, CancellationToken cancellationToken)
{
await ownerService.UpdateAsync(id, owner, cancellationToken);
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
return NoContent();
}
Repeated lookups are cheap, because GetByIdAsync goes through the cache we’ll set up in the caching section.
To read more about filters, visit our article on action filters in ASP.NET Core.
Using DTOs to Return Results and Accept Inputs
We could use the same class to talk to the database and to the client, but that’s not a good practice. A much better one is to separate the entities that map to the database from the objects that travel over HTTP. Yes, the answer is to use DTOs (data transfer objects).
The entity represents a database table. Once we fetch it, we map it to a DTO and return that to the client. If the table changes, we change the entity and the mapping, and the client still gets the same response. Our sample’s output DTO is a simple record:
public record OwnerDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public string? Address { get; init; }
}
We shouldn’t use DTOs only for GET requests. POST and PUT actions get their own input DTOs, and that’s where the validation rules live:
public record OwnerForCreationDto
{
[Required, MaxLength(60)]
public string Name { get; init; } = string.Empty;
[Required, EmailAddress]
public string Email { get; init; } = string.Empty;
[MaxLength(100)]
public string? Address { get; init; }
}
Notice that the creation DTO has no Id. The client can’t choose it, so the DTO doesn’t offer it. That’s the other half of the practice: a DTO protects us from over-posting, where a client sets a property we never meant to expose.
We map between the two by hand in a small extension method:
public static OwnerDto ToDto(this Owner owner) => new()
{
Id = owner.Id,
Name = owner.Name,
Email = owner.Email,
Address = owner.Address
};
For a handful of types, hand-written mapping is the simplest thing that works, and the compiler checks it. When the mappings pile up, a mapping library such as AutoMapper can take over. If you go that way, check the license first: AutoMapper 15 and later is dual-licensed under RPL-1.5 or a commercial license.
DTOs also prevent the circular reference problems we’d get from serializing entities with navigation properties.
You can read more about DTOs for GET requests and for POST, PUT and DELETE requests in our ASP.NET Core series, and about the validation side in our article on ModelState validation in Web API.
Routing
In ASP.NET Core Web API projects, we use attribute routing rather than conventional routing. The route sits right next to the action it serves, and route parameter names match the action’s parameters, so the code reads like the URL. [ApiController] requires it anyway.
We can put a [Route] attribute on the controller and an HTTP method attribute with a template on each action. That pair is what we always use in our projects. Our controller carries [Route("api/owners")], as we saw in the Controllers section, and each action adds its own part:
[HttpGet("{id:guid}", Name = "OwnerById")]
public async Task<ActionResult<OwnerDto>> GetOwnerById(Guid id, CancellationToken cancellationToken)
The :guid constraint means a request like /api/owners/abc doesn’t match the route at all, so it returns 404 without ever reaching our code.
When we talk about routing, we need to mention naming. The action names can be as descriptive as we like, but the routes should use nouns, not verbs. The HTTP method already says what happens.
Don’t name routes like this:
[Route("api/owners")]
public class OwnersController : ControllerBase
{
[HttpGet("getAllOwners")]
public IActionResult GetAllOwners()
{
}
[HttpGet("getOwnerById/{id}")]
public IActionResult GetOwnerById(Guid id)
{
}
}
These are the good examples. GET api/owners returns the collection, and GET api/owners/{id} returns one owner. We also use plural nouns for collections, so the URL reads the same for one owner and for all of them.
For a more detailed explanation of RESTful conventions, check out our REST API best practices.
Logging
If we plan to publish our application to production, we need logging in place. Log messages are what tell us how our software behaves once we can’t attach a debugger to it.
ASP.NET Core has its own logging abstraction, ILogger<T>, and we get it through dependency injection like any other service. Our controller takes ILogger<OwnersController> in its primary constructor.
When we write the log messages, we avoid string interpolation. Don’t do this:
logger.LogInformation($"Returned {owners.Count} owners from the database");
The interpolated string is built even when the log level is switched off, and the logging provider receives one flat string instead of a Count property it could index and query.
The minimum fix is a message template with named placeholders. In our sample we go one step further and let the LoggerMessage source generator write the logging code for us:
public static partial class LogMessages
{
[LoggerMessage(Level = LogLevel.Information, Message = "Returned {Count} owners from the database")]
public static partial void OwnersReturned(this ILogger logger, int count);
}
The call site then reads like a sentence:
logger.OwnersReturned(owners.Count);
ILogger works with many logging providers, so we can send the same messages to the console, files, a database, or an observability platform without changing the code that writes them. NLog and Serilog are both great choices that plug into this system. To learn more, check out logging with NLog in ASP.NET Core and structured logging with Serilog.
Paging, Filtering, Searching, and Sorting
We don’t want to return every resource in a collection when someone queries our API. A big result costs memory, bandwidth, and database time, and in bad cases it brings the whole application down. Microsoft’s own performance guidance makes the same point: return partial results with a page size and a page index.
Paging, filtering, searching, and sorting narrow the result down to what the client actually needs. Each one has its own article in our Web API series:
- paging splits a collection into pages and tells the client how many there are
- filtering returns only the items that match exact criteria, such as a date range
- searching matches items by a search term
- sorting orders the result by one or more fields the client chooses
Two more techniques from the same series shape what comes back rather than how much. Data shaping lets the client pick the fields, and HATEOAS adds links that tell the client what it can do next.
Versioning APIs
Requirements change, and our API has to change with them. But we don’t want to force every consumer to update their code at the same moment. For some clients the old version works just fine, and for others the new one is the way to go. API versioning lets both versions live side by side.
The package for this is Asp.Versioning.Mvc. The older Microsoft.AspNetCore.Mvc.Versioning package is deprecated on NuGet in its favor.
We configure it in another extension method:
public static void ConfigureVersioning(this IServiceCollection services) =>
services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("api-version"));
})
.AddMvc();
Clients that send no version get 1.0, so existing consumers keep working. A client asks for another version with ?api-version=2.0 in the query string or an api-version header, and ReportApiVersions adds an api-supported-versions header to our responses, though not to the ones the output cache replays.
Our OwnersController is marked [ApiVersion("1.0")], and version 2 lives in its own controller on the same route:
[ApiController]
[ApiVersion("2.0")]
[Route("api/owners")]
public class OwnersV2Controller(IOwnerService ownerService) : ControllerBase
{
// Version 2 returns a lighter list: names only.
[HttpGet]
public async Task<ActionResult<IEnumerable<string>>> GetOwners(CancellationToken cancellationToken)
{
var owners = await ownerService.GetAllAsync(cancellationToken);
return Ok(owners.Select(o => o.Name));
}
}
A request to /api/owners?api-version=2.0 now returns ["Anna Bosh","John Keen"], and the same URL without the version still returns the full v1 objects.
The query string and the header are not the only options. We can also put the version in the URL path, with a route like api/v{version:apiVersion}/owners, or read it from the media type. To learn about all of them, read our article on API versioning in ASP.NET Core.
We go into great detail about this feature and all the other best practices in our Ultimate ASP.NET Core Web API program.
Documenting the API With OpenAPI
OpenAPI is the standard description of an HTTP API, and client generators, testing tools and documentation UIs all read it.
Since .NET 9, ASP.NET Core generates the OpenAPI document itself through the Microsoft.AspNetCore.OpenApi package, and the Web API template no longer depends on Swashbuckle.
Two lines from our Program.cs do the work. The first registers the services:
builder.Services.AddOpenApi();
The second maps the endpoint that serves the document, in Development only:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
The document is served at /openapi/v1.json. In .NET 10 it uses OpenAPI 3.1 by default, and our sample’s test checks for it.
Why only in Development? An API description lists every endpoint and parameter, which is more than we want to hand to the whole internet for an internal API. A public API is a different call, and then exposing the document is the point.
The package generates the document but doesn’t ship a UI. We can add Swagger UI or another viewer on top, or keep using Swashbuckle, which still publishes releases for .NET 10. Our articles on Swagger UI in ASP.NET Core Web API and Swashbuckle versus NSwag cover both routes.
Using Asynchronous Code
With async code, we avoid performance bottlenecks and keep our application responsive under load.
When a request arrives, a thread from the thread pool picks it up. If the action then waits for the database synchronously, that thread sits blocked and does nothing for as long as the query takes. With async code, the thread goes back to the pool while the database works, and it’s free to handle other requests. When the data arrives, a thread picks the work up again and sends the response.
If an endpoint takes three seconds to process a request, async code won’t make it any faster. It’s going to take the same three seconds. But no thread is blocked for those three seconds, so the same server handles many more requests at once, and that’s how the solution scales.
So, instead of a synchronous action that blocks a thread for the whole database call (don’t do this):
[HttpGet]
public IActionResult GetOwners()
{
var owners = _repository.GetAll();
return Ok(owners);
}
We write the asynchronous one, all the way down to the repository:
[HttpGet]
[OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)]
public async Task<ActionResult<IEnumerable<OwnerDto>>> GetOwners(CancellationToken cancellationToken)
{
var owners = await ownerService.GetAllAsync(cancellationToken);
logger.OwnersReturned(owners.Count);
return Ok(owners);
}
The async calls have to go all the way down. Calling .Result or .Wait() on a task somewhere in the middle blocks the thread anyway, and Microsoft’s guidance warns that many blocking calls lead to thread pool starvation. The same guidance tells us not to return a lazy IEnumerable<T> straight from a database query, because the serializer then enumerates it synchronously. Materialize it with ToListAsync() first, as our repository does with ToList() over its in-memory data.
For the complete asynchronous repository, read our article on the asynchronous generic repository in ASP.NET Core Web API, and for the mechanics, asynchronous programming with async and await in ASP.NET Core.
Caching
Caching is one of the cheapest ways to make an API faster and to take load off the database. ASP.NET Core gives us three tools, and they solve different problems.
Output caching stores whole HTTP responses on the server. The server decides what to cache and for how long, whatever headers the client sends. We register a policy:
public static void ConfigureOutputCache(this IServiceCollection services) =>
services.AddOutputCache(options =>
options.AddPolicy(OwnersCachePolicy, policy => policy
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByHeader("Accept")
.Tag(OwnersCacheTag)));
And then put [OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)] on the GetOwners action, as we saw in the Controllers section, with app.UseOutputCache() in the pipeline after CORS and the authentication middleware.
By default, output caching stores only 200 responses to GET and HEAD requests, and never caches a response that sets cookies or a response to an authenticated request. Without a policy, entries expire after 60 seconds.
The SetVaryByHeader("Accept") line matters because our API also speaks XML. The cache key is built from the URL alone by default, so without that line, the first client to ask for XML fills the cache, and every JSON client gets XML for the next 30 seconds. We hit exactly this in our sample, and a test now guards it.
Eviction is easy to forget. When an owner is created, updated or deleted, the cached list is stale, so every write action evicts the tag:
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
A test in our sample reads the list, creates an owner, and reads the list again. Without the eviction line, the second read returns the stale list for 30 seconds.
HybridCache caches data rather than responses. We use it when the same object is read by several endpoints, or when we need to cache the result of a database call inside a service. It comes from the Microsoft.Extensions.Caching.Hybrid package and is registered with services.AddHybridCache(). Our OwnerService caches each owner by key:
public async Task<OwnerDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
await cache.GetOrCreateAsync(
$"owner-{id}",
async token => (await repository.GetByIdAsync(id, token))?.ToDto(),
cancellationToken: cancellationToken);
HybridCache keeps entries in memory and, if we register a distributed cache such as Redis, in that second store as well. It also protects us from a cache stampede: when many requests on one server miss the same key at once, only one of them calls the factory and the rest wait for its result. The update and delete methods in OwnerService call cache.RemoveAsync() for the same key.
Response caching is the oldest of the three. It sets HTTP Cache-Control headers and lets browsers and proxies do the caching. It still has its place for public responses, but a client can bypass it by sending Cache-Control: no-cache, which is why it doesn’t help us control server load. For most APIs, output caching replaced it.
Which one should we use? Output caching for whole GET responses, HybridCache for data inside our services, and response caching only when we want clients and proxies to cache for us. To go deeper, read our articles on output caching, hybrid caching, in-memory caching, distributed caching, and response caching.
You can read more about caching, and about all of the topics from this article, in our Ultimate ASP.NET Core Web API program.
Rate Limiting
Every public API eventually meets a client that calls it far too often, by accident or on purpose. Rate limiting caps how many requests a client can make in a window of time and rejects the rest before they cost us any real work. Since .NET 7, the middleware is built into ASP.NET Core.
Here’s our configuration:
public static void ConfigureRateLimiting(this IServiceCollection services, IConfiguration configuration)
{
var permitLimit = configuration.GetValue("RateLimiting:PermitLimit", 100);
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(PerClientRateLimit, httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.User.Identity?.Name
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
});
}
We partition the limiter per client, by user name or, for anonymous callers, by IP address. A single shared limiter would let one noisy client use up the allowance for everybody.
We set RejectionStatusCode to 429 Too Many Requests, because the default is 503 Service Unavailable, which tells the client our server is broken when it isn’t. Our sample has a test that checks this default, so we’ll notice if it ever changes.
Then we apply the policy with [EnableRateLimiting(ServiceExtensions.PerClientRateLimit)] on the controller and add app.UseRateLimiter() to the pipeline. When we use endpoint-specific policies like this one, UseRateLimiter has to come after routing.
One caveat: behind a reverse proxy, RemoteIpAddress is the proxy’s address unless we configure the forwarded headers middleware. Without it, every anonymous client shares one partition. We treat the limiter in the app as a safety net and keep the main limits at the gateway.
Our article on the built-in rate limiting middleware in ASP.NET Core covers the other limiter algorithms: sliding window, token bucket, and concurrency.
Using the ReadFormAsync() Method
There are many cases where we read content from the form body. The common one is a file upload.
The first version of this article started with Request.Form, so let’s look at why we shouldn’t. Don’t do this:
var file = Request.Form.Files[0];
Request.Form reads the form synchronously over an asynchronous stream. That works on a quiet server, but under load it blocks threads and can end in thread pool starvation. Microsoft’s best-practices page puts it plainly: use ReadFormAsync instead of Request.Form.
Here’s the upload action from our sample:
[HttpPost]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(CancellationToken cancellationToken)
{
var formCollection = await Request.ReadFormAsync(cancellationToken);
var file = formCollection.Files.FirstOrDefault();
if (file is null || file.Length == 0)
return BadRequest("No file was uploaded.");
var folder = configuration["Uploads:Path"] ?? "Uploads";
Directory.CreateDirectory(folder);
// Never trust the client's file name: generate our own.
var fileName = Path.GetRandomFileName();
await using var stream = System.IO.File.Create(Path.Combine(folder, fileName));
await file.CopyToAsync(stream, cancellationToken);
return Ok(new { fileName });
}
This action also caps the request size with [RequestSizeLimit], and it never uses the file name the client sent, because a name like ../../appsettings.json could write outside the upload folder.
Once the form has been read with ReadFormAsync, reading Request.Form afterwards is safe, because it returns the cached value. Binding an IFormFile parameter is the other safe route, since model binding reads the form asynchronously too.
To see a full upload example with a front end, read our article on uploading files with ASP.NET Core Web API and Angular.
Hashing Passwords and Protecting Data
Passwords never go into storage as plain text, and the first version of this article mixed up two jobs that need different tools. So let’s separate them.
Hashing is one-way. We hash a password, store the hash, and later check a login attempt against it. We never need the original password back.
Data protection is two-way. We encrypt a value we need to read again later, such as an identifier in a URL or a token in a cookie.
For passwords, we use PasswordHasher<TUser>. It ships with ASP.NET Core, so there’s no package to install. Microsoft’s password hashing documentation is direct about it: “New apps should use the PasswordHasher class.”
This is our sample’s wrapper around it:
public class AccountService(IPasswordHasher<AppUser> passwordHasher)
{
public string HashPassword(AppUser user, string password) =>
passwordHasher.HashPassword(user, password);
public bool VerifyPassword(AppUser user, string hashedPassword, string password) =>
passwordHasher.VerifyHashedPassword(user, hashedPassword, password)
!= PasswordVerificationResult.Failed;
}
We register it with services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>(). The hash it produces carries the algorithm settings and the salt, so we store one string per user.
Notice that we treat SuccessRehashNeeded as a success. That result means the password is right but the hash was made with older settings, so a real login flow would hash the password again and store the new hash. Our article on the default ASP.NET Core Identity password hasher explains the format, and if you need bcrypt instead, read securing passwords with BCrypt.NET.
What about CryptoHelper, the library we recommended in the first version of this article? It’s still maintained, and it still wraps the same PBKDF2 approach. We just don’t need it anymore, because PasswordHasher<TUser> does the job without an extra dependency.
For values we need back, we use the Data Protection API through IDataProtectionProvider:
public class OwnerIdProtector(IDataProtectionProvider provider)
{
private readonly IDataProtector _protector = provider.CreateProtector("WebApiBestPractices.OwnerIds");
public string Protect(Guid ownerId) => _protector.Protect(ownerId.ToString());
public Guid Unprotect(string protectedId) => Guid.Parse(_protector.Unprotect(protectedId));
}
The purpose string ("WebApiBestPractices.OwnerIds") isolates this protector, so a value protected here can’t be unprotected by a protector created for another purpose. We register the system with services.AddDataProtection(). In production, the keys have to be stored somewhere that survives a restart and is shared by every instance of the app, or protected values stop working after the next deployment.
Data protection is never the tool for passwords. Anything we can decrypt, an attacker who gets our keys can decrypt too.
You can read more in our article on protecting data with IDataProtector.
Content Negotiation
By default, an ASP.NET Core Web API returns JSON. In most cases, that’s all we need.
But what if a consumer of our API wants another format, like XML?
Then we have to tell the server how to format the response. This is the part of our Program.cs that does it:
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true;
})
.AddXmlSerializerFormatters();
AddXmlSerializerFormatters() adds the XML formatter. RespectBrowserAcceptHeader makes the API honor the Accept header even when it comes from a browser. ReturnHttpNotAcceptable handles the case where a client asks for a format we don’t support. Instead of quietly returning JSON, our controllers respond with 406 Not Acceptable, which is the best practice.
The first version of this article got this part wrong. Its snippet set RespectBrowserAcceptHeader with a comment saying “Add XML Content Negotiation”, but that flag adds no XML support. Without a formatter, the API has nothing to produce XML with.
The XML serializer needs a public parameterless constructor on the types it writes. That’s why our DTOs are records with init properties rather than positional records.
Content negotiation is a pretty big topic, so if you want to learn more, check out our articles on content negotiation in Web API and returning XML from ASP.NET Core Web API.
What Are the Security Best Practices for an ASP.NET Core Web API?
Security for a Web API comes down to a short list we apply to every project, and most of it is configuration.
We serve the API over HTTPS only and redirect plain HTTP. Every caller authenticates with a standard mechanism, usually JWT bearer tokens, and we validate the issuer, the audience, the lifetime and the signing key on every request. Authorization happens per endpoint with [Authorize] and policies, never with checks scattered through the actions.
Secrets stay out of source control, with user secrets locally and environment variables or a vault in production. We restrict CORS to the origins we know and rate limit public endpoints. Exception details never reach the client, only problem details with a generic message. And we keep the framework and every package patched, because known vulnerabilities are the easiest way in.
Most of those have their own section in this article, and authentication comes next.
JSON Web Tokens (JWT) are the most common way to authenticate API callers, and ASP.NET Core supports them through the Microsoft.AspNetCore.Authentication.JwtBearer package.
We configure it in one more extension method:
public static void ConfigureJwt(this IServiceCollection services, IConfiguration configuration)
{
var jwtSettings = configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["SecretKey"]
?? throw new InvalidOperationException("JwtSettings:SecretKey is not configured.");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["ValidIssuer"],
ValidAudience = jwtSettings["ValidAudience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
};
});
}
Notice the throw. If the signing key is missing, the app refuses to start instead of running with authentication silently broken. The key itself comes from configuration, so it’s never in the code, as we covered in the Environment-Based Settings section.
In the pipeline, app.UseAuthentication() and app.UseAuthorization() come before the rate limiter and the output cache, and then we protect endpoints with [Authorize]. In our sample, deleting an owner requires a valid token:
[Authorize]
[HttpDelete("{id:guid}")]
[ServiceFilter<ValidateOwnerExistsFilter>]
public async Task<IActionResult> DeleteOwner(Guid id, CancellationToken cancellationToken)
{
await ownerService.DeleteAsync(id, cancellationToken);
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
return NoContent();
}
A request with no token gets 401 Unauthorized, and the same request with a valid token gets 204. Our tests check both. We can also put role claims in the token and use them for authorization with [Authorize(Roles = "Admin")] or a policy.
To learn more about JWT authentication and authorization, check out JWT authentication in ASP.NET Core, part 1 and part 2 of our series, and our article on refresh tokens in ASP.NET Core.
ASP.NET Core Identity
If we need user management on top of tokens, such as registration, password reset, email confirmation, or external logins, we don’t have to build it. ASP.NET Core Identity is the membership system that ships with ASP.NET Core. It stores users, hashes their passwords with the same PasswordHasher<TUser> we used above, and handles the flows around them.
Since .NET 8, Identity can also expose ready-made API endpoints for registration and login through MapIdentityApi<TUser>(), which suits a single-page app talking to its own API.
In our ASP.NET Core Identity series, you can learn about those features and how to implement them in your project.
IdentityServer4, Duende IdentityServer, OAuth2 and OpenID Connect
When several applications share one sign-in, we need an authorization server that speaks OAuth 2.0 and OpenID Connect. OAuth 2.0 handles authorization, which is what a client may access, and OpenID Connect adds authentication, which is who the user is.
For years, IdentityServer4 was the default choice in .NET. Today the project’s own README says: “This project is not maintained anymore and is now archived.” It went out of support with .NET Core 3.1 in December 2022, and the README warns that it contains known security vulnerabilities. Don’t start a new project on it, and plan a migration for any existing one.
Its maintainers continued the product as Duende IdentityServer, which is commercial. Duende’s documentation states that “production use requires a license”, and that development, testing and personal projects are free. A free Community Edition covers production use for for-profit organizations under $1M USD in projected annual gross revenue that also have access to less than $3M USD in capital, and for non-profits and registered charities with a published budget under $1M USD. It covers only self-hosted use for your own organization. Redistributing IdentityServer in software you ship, or building identity infrastructure for an end customer, needs a paid license.
Our IdentityServer4, OAuth2 and OpenID Connect series still explains the flows and endpoints well, since OAuth 2.0 and OpenID Connect haven’t changed, but its code targets IdentityServer4. For a current example on Duende, see our article on adding custom claims to an access token in Duende IdentityServer.
Testing Our Applications
We should write tests for our applications as much as we can. From our experience, we know there isn’t always time for it, but tests are how we find bugs during development instead of in production. And they’re how we change code a year later without being afraid of it.
For a Web API, we write two kinds. Unit tests check a service or a handler in isolation, with fakes for its dependencies. Integration tests start the whole application in memory with WebApplicationFactory<Program> from the Microsoft.AspNetCore.Mvc.Testing package and send real HTTP requests to it.
Our sample has 19 tests on xUnit v3, and most of them are integration tests, because the practices in this article live in the pipeline: the exception handler, the filters, the rate limiter, the cache. Here’s the one that proves the exception handler maps our domain exception to a 409:
[Fact]
public async Task CreateOwner_DuplicateEmail_Returns409FromExceptionHandler()
{
await using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/owners",
new OwnerForCreationDto { Name = "John Again", Email = "[email protected]" }, Ct);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(Ct);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("The owner already exists.", problem!.Title);
}
A small .NET 10 convenience: we no longer need a public partial class Program declaration at the bottom of Program.cs for WebApplicationFactory<Program> to see it. Our sample has none, and the tests build and pass.
To learn more, read our ASP.NET Core testing series and our article on integration testing in ASP.NET Core.
Should We Use Minimal APIs Instead of Controllers?
For a new project, Microsoft now recommends Minimal APIs, and that’s a fair default. Controllers remain fully supported, and this article uses them on purpose.
Minimal APIs map a route straight to a lambda or a method, with less ceremony. Controllers group related actions in a class and bring MVC features with them: model binding extensibility, advanced validation through IModelValidator, application parts, and OData support. Microsoft lists those as the reasons to choose controllers.
Almost every practice on this page applies to both. The exception handler, problem details, output caching, HybridCache, rate limiting, OpenAPI, and JWT authentication work the same way. Filters become endpoint filters, [OutputCache] becomes .CacheOutput(), and [EnableRateLimiting] becomes .RequireRateLimiting().
So the choice is mostly about team and codebase. A large API with many related endpoints often reads better as controllers. A small service, or a team starting fresh, often reads better as Minimal APIs.
Our sample has one Minimal API endpoint next to the controllers, serving the same owners from the same service:
app.MapGet("/api/minimal/owners", async (IOwnerService ownerService, CancellationToken cancellationToken) =>
TypedResults.Ok(await ownerService.GetAllAsync(cancellationToken)));
Both styles can live in one application, which also means we can move gradually if we ever want to. To get started, read our introduction to Minimal APIs in .NET.
Conclusion
In this article, our goal was to walk you through the practices we use in our own ASP.NET Core Web API projects. We’ve covered:
- a short
Program.cswith service registration in extension methods - a solution split into API, Application, Domain and Infrastructure projects
- thin controllers and actions, with
[ApiController]doing the validation - one global exception handler that returns problem details
- DTOs at the boundary, attribute routing with nouns, and structured logging
- versioning, OpenAPI, caching, and rate limiting
- async code all the way down, safe file uploads, and proper password hashing
- JWT authentication and tests that prove all of it
Some of these could be used in other frameworks as well, so they’re worth keeping in mind wherever you work.
If you liked this article and want to learn about all these features and more in great detail, we recommend our Ultimate ASP.NET Core Web API program.
If you find that something is missing from the list, don’t hesitate to add it in the comments section.
Tested with .NET 10.0.10 (SDK 10.0.302), Asp.Versioning.Mvc 10.2.1, Microsoft.AspNetCore.OpenApi 10.0.13, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.13 and Microsoft.Extensions.Caching.Hybrid 10.11.0.
