Authentication and Authorization in Minimal APIs
Apply JWT authentication and policy-based authorization to Minimal API endpoints: RequireAuthorization, route groups with shared auth, resource-based authorization, and the patterns that secure clinical APIs.
Auth in Minimal APIs
Authentication and authorization in Minimal APIs use the same system as controllers — same AddJwtBearer, same policies, same IAuthorizationHandler. The difference is where you apply it: .RequireAuthorization() on endpoints or groups instead of [Authorize] attributes.
Setup
// Program.cs — register auth
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.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"]!)),
ClockSkew = TimeSpan.FromSeconds(30),
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("DoctorsOnly", p => p.RequireRole("Doctor"))
.AddPolicy("PharmacyStaff", p => p.RequireRole("Pharmacist", "PharmacyAdmin"))
.AddPolicy("ClinicalStaff", p => p.RequireRole("Doctor", "Nurse", "Pharmacist"))
.AddPolicy("AdminOnly", p => p.RequireRole("Admin"))
.AddPolicy("ScheduleIIPrescriber", p =>
p.RequireRole("Doctor")
.RequireClaim("schedule_ii_prescriber", "true"));
// Middleware — order matters
app.UseAuthentication();
app.UseAuthorization();Endpoint-Level Authorization
// No auth required
app.MapGet("/health", () => Results.Ok()).AllowAnonymous();
app.MapPost("/auth/login", Login).AllowAnonymous();
// Any authenticated user
app.MapGet("/profile", GetProfile).RequireAuthorization();
// Specific role
app.MapPost("/prescriptions", CreatePrescription)
.RequireAuthorization("DoctorsOnly");
// Multiple policies (AND — all must pass)
app.MapPost("/prescriptions/schedule-ii", CreateScheduleIIPrescription)
.RequireAuthorization("DoctorsOnly")
.RequireAuthorization("ScheduleIIPrescriber");Route Groups with Auth
// Apply auth at group level — inherited by all endpoints
var publicRoutes = app.MapGroup("/api/public").AllowAnonymous();
var authRoutes = app.MapGroup("/api").RequireAuthorization();
var doctorRoutes = app.MapGroup("/api/clinical").RequireAuthorization("DoctorsOnly");
var pharmacyRoutes = app.MapGroup("/api/pharmacy").RequireAuthorization("PharmacyStaff");
var adminRoutes = app.MapGroup("/api/admin").RequireAuthorization("AdminOnly");
// Override policy on a specific endpoint within a group
doctorRoutes.MapGet("/patients/{id}", GetPatient)
.RequireAuthorization("ClinicalStaff"); // override — nurses can also viewGlobal Fallback Policy
// All endpoints require auth by default — opt out with AllowAnonymous
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
// Now you cannot forget RequireAuthorization — it is the default
// Explicitly opt out for public endpoints:
app.MapGet("/health", () => Results.Ok("healthy")).AllowAnonymous();
app.MapPost("/auth/login", Login).AllowAnonymous();
app.MapGet("/openapi/v1.json", ServeOpenApi).AllowAnonymous();Reading Claims in Handlers
// ClaimsPrincipal parameter — injected automatically for authenticated requests
app.MapGet("/my-patients", async (
ClaimsPrincipal user,
PatientRepository repo,
CancellationToken ct) =>
{
var doctorId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (doctorId is null) return Results.Unauthorized();
var patients = await repo.GetByDoctorIdAsync(Guid.Parse(doctorId), ct);
return Results.Ok(patients);
}).RequireAuthorization("DoctorsOnly");
// Or use ICurrentUser abstraction
app.MapGet("/my-patients", async (
ICurrentUser currentUser,
PatientRepository repo,
CancellationToken ct) =>
{
var patients = await repo.GetByDoctorIdAsync(currentUser.Id, ct);
return Results.Ok(patients);
}).RequireAuthorization("DoctorsOnly");Resource-Based Authorization
// Authorization depends on the resource — not just the user's identity
app.MapGet("/patients/{id:guid}/records", async (
Guid id,
IAuthorizationService authz,
ClaimsPrincipal user,
PatientRepository repo,
CancellationToken ct) =>
{
var patient = await repo.GetByIdAsync(id, ct);
if (patient is null) return Results.NotFound();
// Check: can this user access THIS patient (same department?)
var authResult = await authz.AuthorizeAsync(user, patient, "SameDepartment");
if (!authResult.Succeeded) return Results.Forbid();
return Results.Ok(patient.ToDetailedDto());
}).RequireAuthorization();Returning the Right Status Codes
// 401 Unauthorized: not authenticated (no token or invalid token)
// 403 Forbidden: authenticated but not authorized (right user, wrong role/resource)
// The RequireAuthorization() middleware handles 401 automatically
// For 403 in resource-based auth:
if (!authResult.Succeeded)
return Results.Forbid(); // 403 — authenticated but not allowed
// Do not return 404 instead of 403 for security reasons
// Returning 404 for "patient exists but you cannot access it" leaks
// the existence of the resource to unauthorized users
// Exception: clinical systems where "I know this patient exists" is also sensitive
// → return 404 even for authorized patients in some contextsAudit Logging for Clinical Endpoints
// Apply audit logging to all clinical endpoints via group filter
var clinicalGroup = app.MapGroup("/api/clinical")
.RequireAuthorization("ClinicalStaff")
.AddEndpointFilter<ClinicalAuditFilter>();
public sealed class ClinicalAuditFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx,
EndpointFilterDelegate next)
{
var user = ctx.HttpContext.User;
var userId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
var path = ctx.HttpContext.Request.Path;
var method = ctx.HttpContext.Request.Method;
// Log access BEFORE the handler — captures intent even if handler crashes
ctx.HttpContext.RequestServices
.GetRequiredService<IAuditLogger>()
.LogClinicalAccess(userId!, method, path);
return await next(ctx);
}
}Red Flag / Green Answer
Red Flag: "We handle 401 and 403 both by returning Results.Unauthorized() (401) everywhere, including when a user is authenticated but lacks access to a specific resource."
Mixing 401 and 403 breaks client applications. 401 means "you need to authenticate." Most client apps respond to 401 by redirecting to the login page. An authenticated user who lacks permission should get 403 — not be redirected to login.
Green Answer:
RequireAuthorization()returns 401 for unauthenticated requests automatically. ReturnResults.Forbid()(403) when the user is authenticated but fails authorization. Client apps distinguish: 401 → redirect to login, 403 → show "access denied" message.
Key Takeaway
Minimal API authorization uses the same policies and handlers as MVC controllers. Apply
.RequireAuthorization()per endpoint or perMapGroup. Use the fallback policy to require auth everywhere — opt out explicitly with.AllowAnonymous(). For resource-based authorization, injectIAuthorizationServiceand callAuthorizeAsyncwith the resource. Return 401 for unauthenticated, 403 for unauthorized — they are different conditions with different client responses.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.