Plain-English explanations, real-world examples, and copy-paste C# code. From zero to a fully working .NET 8 REST API — routing, DI, EF Core, validation, and JWT auth included.
No prior API experience needed.NET 8 · C#Bad vs. Good code
01
What is Minimal API?
You've just learned C#. Now you want to put it on the internet — let people make HTTP requests and have your code respond. The traditional way involved Controllers, Routes, attributes, configuration ceremony… a lot of stuff before you wrote a single line of your code.
Minimal API throws all that ceremony away. One file. One app.MapGet(...) call. Done. You have a working HTTP endpoint.
This isn't a "toy" or "tutorial-only" thing — it's the modern default in .NET 6+. Production APIs ship like this. Microsoft's own tutorials lead with it. If you're starting a new web project today, this is where you start.
Brain Power
If Minimal API is so… minimal — what did the old "MVC Controllers" approach give you that Minimal API "loses"?
(Hint: think about a 200-endpoint enterprise app vs. a 5-endpoint microservice. Same tool? Same constraints?)
MVC Controllers give you organisation — group related endpoints into classes, use attribute routing, filter pipelines, model binders. For a 200-endpoint monolith those scaffolds earn their keep. For a focused microservice with 10 endpoints, the ceremony is overhead. Use Minimal API for small/medium services. Use Controllers for large enterprise codebases. They share the same runtime — you can even mix them in one project.
Big Idea
Controllers vs. Minimal API — what changed?
The traditional MVC approach wraps every endpoint in a Controller class with routing attributes. Minimal API flips that: you define the route and the handler in a single line, with no class required at all.
// ── OLD WAY: MVC Controller ───────────────────
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult Get(int id) { ... }
}
// ── NEW WAY: Minimal API (Program.cs) ─────────
app.MapGet("/api/products/{id}", (int id) => ...);
// That's it. One line.
Why?
When Minimal API wins
Less ceremony means fewer files, faster startup, and an easier on-ramp for beginners. It's the default choice for new .NET 8 projects, microservices, and serverless functions.
// Pros of Minimal API
// ✔ Less boilerplate — no controller classes needed
// ✔ Faster startup — fewer middleware components
// ✔ Easier to learn — one file to understand it all
// ✔ First-class OpenAPI / Swagger support
// ✔ All the same DI, EF Core, Auth that MVC has
// When to stick with MVC controllers
// ✘ Very large team already on MVC
// ✘ Need complex action filters on every endpoint
// ✘ Migrating a huge existing codebase
Structure
What a Minimal API project looks like
Run dotnet new webapi -minimal and you get a single Program.cs. Everything starts there. As the project grows you extract groups into separate files — but the shape stays the same.
MyApi/
├── Program.cs ← entry point, all wiring
├── Models/
│ └── Product.cs ← plain C# record / class
├── Data/
│ └── AppDbContext.cs ← EF Core DbContext
├── Endpoints/
│ └── ProductEndpoints.cs ← extracted endpoint groups
└── MyApi.csproj
// dotnet CLI to create:
dotnet new webapi --use-minimal-apis -n MyApi
cd MyApi
dotnet run
Section 1 wrap — what you now know
Minimal API = modern .NET way to build HTTP APIs with zero ceremony.
One file (Program.cs), one app.MapGet(...), one running server.
Sweet spot: small/medium APIs and microservices.
For 200-endpoint enterprise apps, MVC Controllers still make sense — and you can mix both in one project.
It's not a "starter kit" — it's production-grade and Microsoft's default recommendation.
02
Your First Endpoint
Let's see how small a real, working web API can be. One file. Six lines. A live HTTP endpoint your browser can hit.
Then we'll build it back up — one piece at a time — until every line makes sense and nothing is magic.
Brain Power
When you write app.MapGet("/hello", () => "Hi!") — what's actually happening? A function on a string? A route definition? A web server starting up?
(Answer: all three, in that order.)
The MapGet call registers the endpoint — it tells the framework "when an HTTP GET comes in for /hello, run this lambda." Then app.Run() starts the web server (Kestrel) and starts listening. The lambda only runs when an actual request comes in. Two phases: configure, then serve.
Hello World
The absolute minimum
WebApplication.CreateBuilder wires up DI and configuration. builder.Build() returns the app. Then you map routes and call app.Run(). That's the whole lifecycle.
Each HTTP verb has its own Map* method. The framework automatically deserialises the JSON body into your C# type on POST / PUT.
// GET — read a resource
app.MapGet("/products", () => Results.Ok(products));
// POST — create a resource
app.MapPost("/products", (Product p) => {
products.Add(p);
return Results.Created($"/products/{p.Id}", p);
});
// PUT — replace a resource
app.MapPut("/products/{id}", (int id, Product p) => {
// update logic...
return Results.NoContent();
});
// DELETE — remove a resource
app.MapDelete("/products/{id}", (int id) => {
products.RemoveAll(x => x.Id == id);
return Results.NoContent();
});
Results
Returning HTTP responses correctly
The static Results class gives you typed helpers for every HTTP status. Use them — they produce correct Content-Type headers and status codes automatically.
// ── 200 OK with body ─────────────────────────
return Results.Ok(product);
// ── 201 Created with Location header ─────────
return Results.Created($"/products/{p.Id}", p);
// ── 204 No Content ────────────────────────────
return Results.NoContent();
// ── 400 Bad Request ───────────────────────────
return Results.BadRequest("Name is required.");
// ── 404 Not Found ─────────────────────────────
return Results.NotFound($"Product {id} not found.");
// ── 409 Conflict ──────────────────────────────
return Results.Conflict("SKU already exists.");
// ── Problem Details (RFC 7807) ────────────────
return Results.Problem(
title: "Unprocessable entity",
statusCode: 422);
OpenAPI
Adding Swagger / OpenAPI in 3 lines
.NET 9 ships Microsoft.AspNetCore.OpenApi built-in. Annotate endpoints with .WithName() and .WithTags() to get a fully documented API spec automatically.
// Program.cs
builder.Services.AddOpenApi(); // register OpenAPI
var app = builder.Build();
if (app.Environment.IsDevelopment())
app.MapOpenApi(); // serves /openapi/v1.json
// Annotate endpoints:
app.MapGet("/products/{id}", (int id) => ...)
.WithName("GetProductById")
.WithTags("Products")
.WithSummary("Fetch a single product by ID")
.Produces<Product>(200)
.Produces(404);
// Swagger UI (add Scalar package):
// app.MapScalarApiReference();
Section 2 wrap — what you now know
WebApplication.CreateBuilder(args) + builder.Build() = the two-phase setup pattern.
app.MapGet / MapPost / MapPut / MapDelete = one verb per HTTP method.
Return values are automatically serialised to JSON. Strings become text. Results.Ok(obj) and friends give you status-code control.
app.Run() starts the Kestrel web server and blocks until shutdown.
OpenAPI / Swagger UI = two lines (AddOpenApi + MapOpenApi) and you have interactive API docs at /openapi.
03
Routing & Parameters
Your endpoint says "give me a customer". The HTTP request comes in as /customers/42?include=orders with a JSON body and a bearer token. How does that messy URL turn into clean, typed parameters in your lambda?
Magic — but the helpful kind. Minimal API binds parameters automatically from four sources: the route (parts of the URL), the query string (after the ?), the body (JSON), and the headers. The framework name-matches and type-checks to pick the right source.
Most of the time it Just Works. When you want to be explicit (or override the default), there's an attribute for every source.
Brain Power
You write app.MapGet("/users/{id}", (int id, string? name) => ...). Where does each parameter come from? Will it crash if there's no name in the URL?
id is a route parameter (its name matches {id} in the template). name isn't in the route, so Minimal API falls back to the query string — ?name=foo. The ? on string? says nullable, so a missing name binds to null without complaint. Name match + type check + source order. Once you internalise the order, parameter binding stops feeling magical and starts feeling predictable.
Route Params
Capturing values from the URL path
Wrap a segment in {curly braces} in the route template. The framework automatically parses it to the matching parameter type — int, Guid, string, etc.
Any parameter whose name is not in the route template is treated as a query string value. Make it nullable or give it a default to make it optional.
// GET /products?category=Books&page=2&pageSize=20
app.MapGet("/products", (
string? category, // ?category=Books (optional)
int page = 1,
int pageSize = 20) =>
{
var query = db.Products.AsQueryable();
if (category is not null)
query = query.Where(p => p.Category == category);
return query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
});
// Nullable bool flag
// GET /products?inStock=true
app.MapGet("/products/search", (bool? inStock) => ...);
Body
Reading JSON from the request body
Declare a complex type as a parameter and the framework automatically deserialises the JSON body into it. Use a C# record with positional parameters for concise, immutable request DTOs.
// Define the DTO (Data Transfer Object)
public record CreateProductRequest(
string Name,
decimal Price,
int Stock,
string Category);
// The framework reads the body and maps it
app.MapPost("/products", async (
CreateProductRequest req,
AppDbContext db) =>
{
var product = new Product
{
Name = req.Name,
Price = req.Price,
Stock = req.Stock,
Category = req.Category
};
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});
// JSON body sent by the client:
// { "name": "Laptop", "price": 999.99, "stock": 5, "category": "Tech" }
Headers / Services
Binding from headers, services & HttpContext
Use explicit binding attributes when the name alone isn't enough — inject headers, claim values, or services right in the parameter list.
using Microsoft.AspNetCore.Mvc; // [FromHeader], [FromQuery]
app.MapGet("/me", (
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
[FromServices] ILogger<Program> logger,
HttpContext ctx) =>
{
var userId = ctx.User.FindFirst("sub")?.Value;
logger.LogInformation("User {Id} called /me", userId);
return Results.Ok(new { UserId = userId, correlationId });
});
// [FromQuery] forces query-string even if name clashes
app.MapGet("/search", ([FromQuery] string q) => ...);
// Inject CancellationToken for long operations
app.MapGet("/slow", async (CancellationToken ct) =>
{
await Task.Delay(5000, ct);
return Results.Ok("done");
});
Groups
Organising endpoints with MapGroup
MapGroup shares a common prefix and lets you attach middleware (auth, rate-limiting) to a whole family of endpoints without repeating yourself.
// All /api/v1/products/* endpoints in one group
var products = app.MapGroup("/api/v1/products")
.WithTags("Products")
.RequireAuthorization(); // all routes need a valid JWT
products.MapGet("/", GetAll);
products.MapGet("/{id}", GetById);
products.MapPost("/", Create);
products.MapPut("/{id}", Update);
products.MapDelete("/{id}", Delete);
// Extract to a static class for large APIs
public static class ProductEndpoints
{
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/products");
group.MapGet("/", GetAll);
// ...
return app;
}
}
Section 3 wrap — what you now know
Route parameters live in the URL template: "/users/{id}" binds to the parameter named id.
Query parameters are the fallback if no route or attribute matches: ?name=foo.
Body binding is automatic for complex types in POST/PUT — JSON in, typed object out.
Headers bind via [FromHeader] — useful for tokens, correlation IDs.
Nullable types (string?) mean "optional"; non-nullable means "required, 400 if missing".
When in doubt, use [FromRoute], [FromQuery], [FromBody] attributes to make the source explicit.
04
Dependency Injection & Services
Remember the Dependency Inversion Principle from the OOP/SOLID guide? "Depend on interfaces, not concrete classes." Dependency Injection is how .NET makes that practical — you ask for an IUserRepository, the framework hands you the right concrete implementation, you never write new SqlUserRepository() in your business code.
In Minimal API, DI is identical to MVC — register services in the container, then inject them directly into your endpoint lambda parameters. No [FromServices] attribute needed; the framework figures out "this isn't from the request, it must be from the container".
Three service lifetimes (Singleton, Scoped, Transient) decide how often the framework hands you a new instance. Picking the right one is one of the highest-leverage choices in your API's design.
Brain Power
You register a DbContext as Singleton. The app runs fine in tests. In production it starts throwing weird "context disposed" errors under load. What went wrong, and which lifetime should you have used?
DbContext is not thread-safe. As a Singleton, every request shares the same instance, and concurrent requests step on each other. The right lifetime is Scoped — one instance per HTTP request, disposed when the request ends. Singleton = "one for the entire app" (logger, config). Scoped = "one per request" (DbContext, anything with per-user state). Transient = "new every time" (lightweight, stateless services).
Register
Lifetimes: Singleton · Scoped · Transient
Pick the right lifetime or you'll get subtle bugs — especially the "captive dependency" trap where a Singleton captures a Scoped service.
// ── Transient — new instance every injection
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// ── Scoped — one instance per HTTP request
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// ── Singleton — one instance for the app's lifetime
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
// ── Options pattern for config ────────────────
builder.Services.Configure<JwtSettings>(
builder.Configuration.GetSection("Jwt"));
// ── Common built-ins ──────────────────────────
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddLogging();
Inject
Services flow straight into the lambda
The framework inspects parameter types at startup. Any type registered in the DI container is resolved automatically — no attribute, no constructor, no controller.
// AppDbContext + ILogger injected automatically
app.MapGet("/products/{id}", async (
int id,
AppDbContext db,
ILogger<Program> log) =>
{
log.LogInformation("Fetching product {Id}", id);
var product = await db.Products.FindAsync(id);
return product is null
? Results.NotFound()
: Results.Ok(product);
});
// Custom service injected
app.MapPost("/orders", async (
CreateOrderRequest req,
IOrderService svc,
CancellationToken ct) =>
{
var order = await svc.PlaceOrderAsync(req, ct);
return Results.Created($"/orders/{order.Id}", order);
});
Pattern
Keeping endpoints thin with a service layer
Fat lambdas are hard to test. Extract business logic into a service class, inject it, and keep the endpoint down to 5 lines or fewer.
DI = framework hands you what you need, so your code depends on interfaces, not concrete classes.
builder.Services.AddScoped<IUserRepository, SqlUserRepository>() = "when someone asks for IUserRepository, give them a SqlUserRepository".
Three lifetimes: Singleton (one for the app), Scoped (one per HTTP request), Transient (new every time).
DbContext = Scoped, always. Singleton DbContext is the #1 production bug source on this list.
You don't need [FromServices] in Minimal API — anything not from the request is from the container.
DI in Minimal API is the same DI you use in MVC, Blazor, Worker Services, etc. Learn it once, use it everywhere.
05
Working with Data
An API without data is just a fancy "echo" service. Time to talk to a database.
Minimal API isn't opinionated about how you access data. EF Core if you want a full ORM (LINQ → SQL, change tracking, migrations). Dapper if you want raw SQL with minimal mapping. ADO.NET if you want absolute control. All three slot in via DI; the framework just makes sure the connection is opened and closed at the right times.
The big architectural decision isn't "which library" — it's where the data access lives. Inline in the endpoint? In a repository class? Behind a service? The right answer depends on how big your API will get.
Brain Power
You write a 4-line endpoint that does db.Users.Where(...).ToListAsync() directly in the lambda. Six months later there are 30 endpoints, all doing similar queries, some with subtle bugs.
At what point should you have extracted a UserRepository? And what's the OOP/SOLID principle telling you that?
The rule of thumb: extract to a repository when the same query (or near-duplicate) appears in three places. That's the Single Responsibility Principle ringing the bell — the endpoint shouldn't be both "handle HTTP" AND "talk to the database". The repository owns the queries; the endpoint orchestrates. Now adding caching, switching databases, or fixing one query fixes them all at once.
EF Core
DbContext setup & full CRUD
Register the DbContext once in Program.cs, then inject it anywhere. EF Core handles connection pooling, change tracking, and migrations automatically.
// ── Model ─────────────────────────────────────
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public string Category { get; set; } = "";
}
// ── DbContext ─────────────────────────────────
public class AppDbContext(DbContextOptions<AppDbContext> opts)
: DbContext(opts)
{
public DbSet<Product> Products => Set<Product>();
}
// ── Program.cs: register ──────────────────────
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(
builder.Configuration.GetConnectionString("Default")));
// ── Full CRUD ─────────────────────────────────
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.ToListAsync());
app.MapGet("/products/{id}", async (int id, AppDbContext db) =>
await db.Products.FindAsync(id)
is Product p ? Results.Ok(p) : Results.NotFound());
app.MapPost("/products", async (Product p, AppDbContext db) => {
db.Products.Add(p);
await db.SaveChangesAsync();
return Results.Created($"/products/{p.Id}", p);
});
app.MapPut("/products/{id}", async (int id, Product input, AppDbContext db) => {
var p = await db.Products.FindAsync(id);
if (p is null) return Results.NotFound();
p.Name = input.Name;
p.Price = input.Price;
await db.SaveChangesAsync();
return Results.NoContent();
});
app.MapDelete("/products/{id}", async (int id, AppDbContext db) => {
if (await db.Products.FindAsync(id) is not Product p) return Results.NotFound();
db.Products.Remove(p);
await db.SaveChangesAsync();
return Results.NoContent();
});
Migrations
Schema changes with EF Core Migrations
Migrations translate C# model changes into SQL scripts. Run them from the CLI — the database schema stays in sync with your code automatically.
// Add EF Core tools once (global)
dotnet tool install -g dotnet-ef
// Add required packages to your project
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
// Create the first migration
dotnet ef migrations add InitialCreate
// Apply it to the database
dotnet ef database update
// ── Apply migrations on startup (dev only) ────
// In Program.cs:
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// ── Typical migration workflow ─────────────────
// 1. Change your model class
// 2. dotnet ef migrations add AddCategoryColumn
// 3. dotnet ef database update
Dapper
Raw SQL with Dapper (micro-ORM)
When you need hand-tuned SQL for performance-critical reads, Dapper maps result sets to C# objects in one line — with zero overhead and full parameterisation.
// NuGet: dotnet add package Dapper
// NuGet: dotnet add package Microsoft.Data.SqlClient
builder.Services.AddScoped<IDbConnection>(_ =>
new SqlConnection(
builder.Configuration.GetConnectionString("Default")));
// Inject IDbConnection into the endpoint
app.MapGet("/products/search", async (
string keyword,
IDbConnection db) =>
{
var sql = """
SELECT Id, Name, Price, Category
FROM dbo.Products
WHERE Name LIKE @Keyword
ORDER BY Name
""";
var results = await db.QueryAsync<Product>(sql,
new { Keyword = $"%{keyword}%" });
return Results.Ok(results);
});
// Multi-mapping (JOIN result → two objects)
var orders = await db.QueryAsync<Order, Customer, Order>(
sql, (o, c) => { o.Customer = c; return o; },
splitOn: "CustomerId");
Section 5 wrap — what you now know
EF Core = full ORM, LINQ → SQL, change tracking, migrations. Use for complex domains.
Dapper = micro-ORM, raw SQL with object mapping. Use for performance-critical paths.
ADO.NET = the metal. Reach for it when you need absolute control.
Register your DbContext with AddDbContext<TContext>() — scoped lifetime by default.
Use async/await for every database call. Blocking I/O = thread pool exhaustion under load.
Extract a Repository when the same query appears in three endpoints. That's SRP whispering.
Never put SQL strings in endpoint lambdas. Build them once in the repository where they're testable.
06
Validation & Error Handling
Your API is on the internet. Everyone sees it. Including the people sending negative ages, JSON bombs, SQL injection attempts, and the occasional accidentally-malformed integer.
Never trust user input. Validate early, fail clearly, return predictable error responses. This isn't paranoia — it's the difference between an API that holds up in production and one that hands the database to the first curious stranger.
The right pattern: validate up front (FluentValidation or simple manual checks), return standardised Problem Details (RFC 7807) so clients always parse errors the same way, and handle exceptions globally with one middleware — don't sprinkle try/catch inside every endpoint.
Brain Power
Your endpoint throws because of a null reference. Without a global error handler, what does the caller see? With one, what should they see?
(Hint: one of these accidentally leaks stack traces to attackers.)
Without a handler, the framework returns the raw exception — stack trace, internal class names, file paths — straight to the caller. Production: that's an information disclosure vulnerability. With a global exception handler returning Problem Details: the caller sees { "title": "Internal error", "status": 500 } and you log the full trace server-side where only you can see it. Two minutes of middleware. Saves your security review.
FluentValidation
Declarative rules with FluentValidation
Define validation rules in a dedicated AbstractValidator<T> class, register it as a service, and inject it into the endpoint. Clean, testable, zero reflection magic.
// dotnet add package FluentValidation.DependencyInjectionExtensions
public record CreateProductRequest(string Name, decimal Price, int Stock);
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(200);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be positive.");
RuleFor(x => x.Stock)
.GreaterThanOrEqualTo(0);
}
}
// Register all validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Use in endpoint
app.MapPost("/products", async (
CreateProductRequest req,
IValidator<CreateProductRequest> validator,
AppDbContext db) =>
{
var result = await validator.ValidateAsync(req);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
var product = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);
});
Error Handling
Global exception middleware
One central place catches every unhandled exception and returns a consistent RFC 7807 Problem Details response — no try/catch scattered across endpoints.
// ── .NET 8: UseExceptionHandler ───────────────
app.UseExceptionHandler(errApp => errApp.Run(async ctx =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
var log = ctx.RequestServices.GetRequiredService<ILogger<Program>>();
log.LogError(ex, "Unhandled exception");
ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
ctx.Response.ContentType = "application/problem+json";
await ctx.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc7807",
title = "An unexpected error occurred.",
status = 500,
traceId = ctx.TraceIdentifier
});
}));
// ── .NET 8 also ships IExceptionHandler ───────
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> log)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
log.LogError(ex, "Unhandled exception");
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsJsonAsync(
new ProblemDetails { Title = "Server error", Status = 500 }, ct);
return true;
}
}
// Register:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
Endpoint Filter
Reusable validation filter
Endpoint filters run before and after the handler — perfect for extracting validation into a reusable pipeline step that can be applied to multiple endpoints.
// Generic validation filter
public class ValidationFilter<T>(IValidator<T> validator)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx,
EndpointFilterDelegate next)
{
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is not null)
{
var result = await validator.ValidateAsync(arg);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(ctx);
}
}
// Apply to an endpoint (or entire group)
app.MapPost("/products", async (CreateProductRequest req, AppDbContext db) =>
{
// Validation already ran — req is guaranteed valid here
var p = new Product { Name = req.Name, Price = req.Price };
db.Products.Add(p);
await db.SaveChangesAsync();
return Results.Created($"/products/{p.Id}", p);
})
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Section 6 wrap — what you now know
Validate early, fail loudly. Catch bad input at the door, not three layers deep.
FluentValidation is the .NET standard for declarative validation rules.
Problem Details (RFC 7807) = the standard JSON shape for API errors. Use Results.Problem(...).
One global exception handler beats try/catch sprinkled in every endpoint.
Never leak stack traces to the client. Log them server-side; return generic 500 to callers.
Use HTTP status codes correctly: 200 (OK), 201 (Created), 204 (No Content), 400 (Bad Input), 401 (Unauthorised), 403 (Forbidden), 404 (Not Found), 500 (Server Error).
If your API never returns 4xx, your validation isn't strict enough.
07
Auth & Cheatsheet
Your API works. You can validate input. Now: how do you make sure only the right people can call it?
The modern web answer is JWT — JSON Web Tokens. The client gets a token after logging in (signed by the server), attaches it to every request as Authorization: Bearer ..., and the API verifies it on the way in. Stateless. Scalable. The standard for REST APIs in 2025.
Minimal API integrates with JWT in three lines: AddAuthentication().AddJwtBearer(...) in setup, .RequireAuthorization() on the endpoints you want to protect. We'll finish with a cheatsheet you can keep open while you build.
Brain Power
The frontend developer says "let's just store the JWT in localStorage and send it on every request." Sounds reasonable. What's the security trap?
localStorage is readable by any JavaScript on the page — including malicious script injected via XSS. A successful XSS attack against your site means every user's JWT just walked out the door. The safer pattern: store JWT in an HttpOnly cookie. JavaScript can't touch it, the browser sends it automatically, XSS can't steal it. Trade-off: cookie-based auth needs CSRF protection. There's no free lunch — only choices about which threat to defend against first.
JWT Auth
JWT bearer authentication in 10 lines
Add the package, configure the secret key, register middleware, then lock endpoints with .RequireAuthorization(). The framework validates the token and populates HttpContext.User automatically.
// dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
// ── Program.cs ────────────────────────────────
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // must come before UseAuthorization
app.UseAuthorization();
// ── Secure individual endpoints ───────────────
app.MapGet("/profile", (HttpContext ctx) =>
{
var userId = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Results.Ok(new { userId });
})
.RequireAuthorization();
// ── Secure an entire group ────────────────────
var admin = app.MapGroup("/admin").RequireAuthorization("AdminPolicy");
admin.MapGet("/stats", () => Results.Ok(GetStats()));
Token Generation
Issuing a JWT from a login endpoint
The login endpoint validates credentials, builds a claims identity, signs it with the secret key, and returns the token string. The client sends it back as Authorization: Bearer <token> on every subsequent request.
public record LoginRequest(string Email, string Password);
app.MapPost("/auth/login", (LoginRequest req, IConfiguration cfg) =>
{
// Replace with real credential check
if (req.Email != "admin@demo.com" || req.Password != "secret")
return Results.Unauthorized();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "1"),
new Claim(ClaimTypes.Email, req.Email),
new Claim(ClaimTypes.Role, "Admin")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: cfg["Jwt:Issuer"],
audience: cfg["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return Results.Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(token)
});
})
.AllowAnonymous()
.WithTags("Auth");
Policies
Role & policy-based authorisation
Named policies let you express complex rules once and reference them by name on any endpoint or group — far cleaner than sprinkling attribute parameters everywhere.
builder.Services.AddAuthorization(opt =>
{
// Simple role check
opt.AddPolicy("AdminOnly", p => p.RequireRole("Admin"));
// Claim-based policy
opt.AddPolicy("PremiumUser", p =>
p.RequireClaim("subscription", "premium", "enterprise"));
// Combined — authenticated AND UK-based
opt.AddPolicy("UKAdmin", p =>
{
p.RequireAuthenticatedUser();
p.RequireRole("Admin");
p.RequireClaim("country", "UK");
});
});
// Apply by name
app.MapDelete("/products/{id}", ...)
.RequireAuthorization("AdminOnly");
app.MapGet("/reports", ...)
.RequireAuthorization("PremiumUser");
// Read role inside an endpoint
app.MapGet("/whoami", (HttpContext ctx) =>
{
var role = ctx.User.FindFirst(ClaimTypes.Role)?.Value;
return Results.Ok(new { role });
})
.RequireAuthorization();
Cheatsheet
Everything you need in one scroll
Keep this tab open. When in doubt, find the line below and copy it.
JWT = signed token the client sends on every request. Stateless, scalable, the REST API standard.
Setup is three calls: AddAuthentication().AddJwtBearer(...) + UseAuthentication()/UseAuthorization().
Protect endpoints with .RequireAuthorization(). Add policies for role/claim checks.
HttpOnly cookies > localStorage for token storage — XSS can't reach the cookie.
Always set short JWT expiry + refresh tokens. Long-lived tokens are stolen tokens.
The cheatsheet card has the most-used patterns — bookmark this page; you'll be back.
Mastery Move — where to go next
You now know enough to build and ship a production Minimal API. The natural next jumps:
1. OOP & SOLID — the architectural foundation that turns a 50-endpoint API from "works" to "maintainable". Dependency Injection is the Dependency Inversion Principle in framework form.
2. C# Deep-Dive — async/await, advanced LINQ, source generators. Every concept here gets used in production API code.
Things this tutorial deliberately skipped: rate limiting, output caching, HATEOAS, OpenAPI versioning, integration testing with WebApplicationFactory. All worth learning after you've shipped something real.