Learnixo
Back to blog
AI Systemsintermediate

Authentication in Minimal APIs — Endpoints, Filters, and Patterns

Apply JWT authentication and authorization to ASP.NET Core Minimal APIs: endpoint-level requirements, route groups with shared auth, endpoint filters for pre-authorization logic, and production patterns.

Asma Hafeez KhanMay 16, 20264 min read
Minimal APIsAuthenticationAuthorizationASP.NET Core.NET
Share:𝕏

Auth in Minimal APIs vs Controllers

In controllers, [Authorize] is applied to the class or action. In Minimal APIs, authentication is applied via fluent chaining on the endpoint or route group.

C#
// Controller style
[Authorize(Policy = "DoctorsOnly")]
public async Task<IActionResult> GetPatient(Guid id) { ... }

// Minimal API style
app.MapGet("/patients/{id}", GetPatient)
    .RequireAuthorization("DoctorsOnly");

The authorization system underneath is identical — same policies, same handlers, same IAuthorizationService. Only the application point differs.


Basic Setup

C#
// Program.cs — authentication and authorization must be registered
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => { /* ... validation parameters ... */ });

builder.Services.AddAuthorization();

var app = builder.Build();

// Middleware order matters
app.UseAuthentication();   // 1. who are you?
app.UseAuthorization();    // 2. what can you do?

Endpoint-Level Authorization

C#
// No auth required
app.MapGet("/health", () => Results.Ok("healthy"));

// Require any authenticated user
app.MapGet("/patients", GetAllPatients)
    .RequireAuthorization();

// Require specific role
app.MapPost("/prescriptions", AddPrescription)
    .RequireAuthorization("DoctorsOnly");

// Require specific claim
app.MapPost("/prescriptions/schedule-ii", AddScheduleII)
    .RequireAuthorization("CanPrescribeScheduleII");

// Multiple policies (all must pass)
app.MapDelete("/patients/{id}", DeletePatient)
    .RequireAuthorization("AdminOnly")
    .RequireAuthorization("AuditLogged");

// Explicit allow anonymous (override global default)
app.MapGet("/api/docs", GetApiDocs)
    .AllowAnonymous();

Route Groups with Shared Auth

C#
// Group endpoints by auth requirement
var publicRoutes = app.MapGroup("/api/public")
    .AllowAnonymous();

var authenticatedRoutes = app.MapGroup("/api")
    .RequireAuthorization();

var doctorRoutes = app.MapGroup("/api/clinical")
    .RequireAuthorization("DoctorsOnly");

var adminRoutes = app.MapGroup("/api/admin")
    .RequireAuthorization("AdminOnly");

// Map endpoints into groups — they inherit the group's auth
publicRoutes.MapGet("/health",   GetHealth);
publicRoutes.MapPost("/auth/login", Login);

authenticatedRoutes.MapGet("/profile", GetProfile);

doctorRoutes.MapGet("/patients",         GetPatients);
doctorRoutes.MapPost("/prescriptions",   AddPrescription);
doctorRoutes.MapGet("/drug-interactions",GetDrugInteractions);

adminRoutes.MapGet("/users",   GetAllUsers);
adminRoutes.MapPost("/users",  CreateUser);

Reading the Authenticated User

C#
// Option 1: ClaimsPrincipal parameter injection (ASP.NET Core 7+)
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    var userId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
    var email  = user.FindFirstValue(JwtRegisteredClaimNames.Email);
    return Results.Ok(new { UserId = userId, Email = email });
}).RequireAuthorization();

// Option 2: HttpContext injection
app.MapGet("/me", (HttpContext ctx) =>
{
    var userId = ctx.User.FindFirstValue(JwtRegisteredClaimNames.Sub);
    return Results.Ok(new { UserId = userId });
}).RequireAuthorization();

// Option 3: Custom ICurrentUser service (preferred in Clean Architecture)
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");

Endpoint Filters for Pre-Authorization Logic

C#
// Filter that validates the request before the handler runs
public sealed class AuditLogFilter : IEndpointFilter
{
    private readonly IAuditLogger _audit;

    public AuditLogFilter(IAuditLogger audit) => _audit = audit;

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var user     = context.HttpContext.User;
        var endpoint = context.HttpContext.GetEndpoint()?.DisplayName;

        await _audit.LogAccessAsync(
            userId:   user.FindFirstValue(JwtRegisteredClaimNames.Sub),
            endpoint: endpoint,
            method:   context.HttpContext.Request.Method);

        var result = await next(context);

        await _audit.LogResponseAsync(
            context.HttpContext.Response.StatusCode);

        return result;
    }
}

// Apply per group or endpoint
doctorRoutes.AddEndpointFilter<AuditLogFilter>();

Global Fallback Authorization Policy

C#
// Require authentication everywhere by default
// Then opt-in to specific requirements or anonymous per-endpoint
builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build());

// Now ALL endpoints require auth unless .AllowAnonymous() is added
// This is safer than opting in per-endpoint — you cannot forget

Returning 401 vs 403

C#
// Results.Unauthorized() → 401 (not authenticated)
// Results.Forbid()       → 403 (authenticated but not authorized)

app.MapGet("/admin", async (ICurrentUser user, IAuthorizationService authz) =>
{
    var authResult = await authz.AuthorizeAsync(
        user.Principal, "AdminOnly");

    if (!authResult.Succeeded)
        return Results.Forbid();  // 403 — user IS authenticated but not admin

    // ...
}).RequireAuthorization();  // this handles 401 automatically

Production issue I've seen: An API returned 401 for all auth failures — including "authenticated but wrong role." Client apps treated all 401s as "session expired, log the user out." Legitimate users with a valid session but wrong role were being logged out. Using 403 for authorization failures and 401 only for authentication failures fixed the user experience.


Red Flag / Green Answer

Red Flag: "We check User.Identity.IsAuthenticated manually at the top of every endpoint handler."

Manual checks are error-prone and inconsistent. An endpoint that forgets the check exposes protected data. Use .RequireAuthorization() or the fallback policy — the framework enforces it before the handler runs.

Green Answer:

.RequireAuthorization() on every protected endpoint or route group. Global fallback policy as a backstop. AllowAnonymous() to explicitly opt out. Framework enforces auth before the handler executes.


Key Takeaway

Minimal API authentication uses the same underlying system as controllers — same policies, same handlers, applied via .RequireAuthorization() on endpoints and route groups. Group related endpoints into MapGroup() and apply shared auth at the group level to avoid repetition. Use the fallback policy to require authentication everywhere by default — it is safer than opt-in per endpoint.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.