BrandGhost
Authentication and Authorization in ASP.NET Core Web API: JWT and Policies

Authentication and Authorization in ASP.NET Core Web API: JWT and Policies

ASP.NET Core authentication is built on a flexible, pluggable middleware system that handles one of the most critical responsibilities in any web application -- verifying who a caller is and what they're allowed to do. It's worth being precise about the distinction up front. Authentication answers "who are you?" -- it establishes identity. Authorization answers "what are you allowed to do?" -- it enforces access rules based on that established identity. The two work together, but they are separate concerns, and ASP.NET Core models them that way.

This article focuses on JWT bearer authentication -- a common default for many stateless Web APIs, though internal APIs, BFF patterns, mTLS, and opaque token introspection are also real options depending on the context. You'll see the full setup from scratch, learn how to issue tokens, apply policy-based authorization, and handle the scenarios that trip developers up most often. All examples target .NET 10.

Authentication Architecture

At the heart of ASP.NET Core's authentication system is the IAuthenticationService, which sits behind the UseAuthentication() middleware. When a request arrives, the middleware calls AuthenticateAsync on the default authentication scheme. A scheme is a named configuration that maps to a specific authentication handler -- JWT bearer, cookie, OAuth, and so on. The handler reads the incoming request (typically the Authorization header for bearer tokens), validates the credential, and produces a ClaimsPrincipal if successful.

The ClaimsPrincipal is the core identity object. It contains one or more ClaimsIdentity objects, each of which contains a flat collection of claims -- key-value pairs like sub (subject/user ID), email, role, and any custom claims your application adds. Once the middleware has populated HttpContext.User with this principal, every subsequent middleware and every endpoint in the pipeline has access to the caller's identity.

Authentication schemes are registered in Program.cs during service configuration. Multiple schemes can coexist, and the framework supports per-endpoint scheme selection. For most Web API projects, a single JWT bearer scheme is sufficient.

JWT Bearer Authentication Setup

Setting up JWT bearer authentication requires two things: configuring the token validation parameters so the middleware knows how to verify incoming tokens, and adding the UseAuthentication() middleware to the pipeline before any endpoint that needs identity. The asp.net core authentication middleware system handles all of this cleanly once you understand the configuration model.

The TokenValidationParameters class controls what the middleware checks when it receives a bearer token. At minimum, you should validate the issuer (the party that issued the token), the audience (the intended recipient), the token lifetime, and the issuer signing key. Skipping any of these opens the door to security vulnerabilities.

// Program.cs -- Full JWT bearer authentication setup in .NET 10
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

var jwtSection = builder.Configuration.GetSection("Jwt");
var secretKey = jwtSection["SecretKey"]
    ?? throw new InvalidOperationException("JWT SecretKey is not configured.");

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = jwtSection["Issuer"],

        ValidateAudience = true,
        ValidAudience = jwtSection["Audience"],

        ValidateLifetime = true,
        ClockSkew = TimeSpan.FromSeconds(30),

        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(secretKey))
    };

    options.Events = new JwtBearerEvents
    {
        OnAuthenticationFailed = ctx =>
        {
            // Log authentication failures without leaking details to the client
            var logger = ctx.HttpContext.RequestServices
                .GetRequiredService<ILogger<Program>>();
            logger.LogWarning("JWT authentication failed: {Error}",
                ctx.Exception.Message);
            return Task.CompletedTask;
        }
    };
});

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication(); // Must come before UseAuthorization
app.UseAuthorization();

app.MapControllers();

app.Run();

The order of UseAuthentication() and UseAuthorization() matters. Authentication must run first -- it establishes identity. Authorization runs second and uses that identity to make access decisions. Reversing the order is one of the most common mistakes in new ASP.NET Core projects, and it results in authorization policies silently falling back to anonymous access.

For structured logging setup across your application, the complete .NET logging guide covers the full picture, and setting up Serilog in ASP.NET Core walks through the specific configuration steps.

Generating JWT Tokens

Authentication middleware validates tokens -- but something in your application has to issue them first. The standard pattern is a login endpoint that accepts credentials, validates them, and returns a signed JWT if successful. The JwtSecurityTokenHandler class handles token creation.

A well-formed JWT token includes an issuer, audience, expiration, and a set of claims that represent the authenticated user's identity. The signing key used to issue the token must match the key configured in TokenValidationParameters, or validation will fail with a signature verification error.

// Login endpoint that issues a JWT token
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

[ApiController]
[Route("api/[controller]")]
public sealed class AuthController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly IConfiguration _configuration;
    private readonly ILogger<AuthController> _logger;

    public AuthController(
        IUserService userService,
        IConfiguration configuration,
        ILogger<AuthController> logger)
    {
        _userService = userService;
        _configuration = configuration;
        _logger = logger;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest request)
    {
        var user = await _userService.ValidateCredentialsAsync(
            request.Email, request.Password);

        if (user is null)
        {
            _logger.LogWarning("Failed login attempt for {Email}", request.Email);
            return Unauthorized(new { message = "Invalid credentials." });
        }

        var token = GenerateToken(user);
        return Ok(new { token });
    }

    private string GenerateToken(ApplicationUser user)
    {
        var jwtSection = _configuration.GetSection("Jwt");
        var secretKey = jwtSection["SecretKey"]!;
        var signingKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(secretKey));

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(ClaimTypes.Name, user.DisplayName),
        };

        foreach (var role in user.Roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        var descriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Issuer = jwtSection["Issuer"],
            Audience = jwtSection["Audience"],
            Expires = DateTime.UtcNow.AddHours(1),
            SigningCredentials = new SigningCredentials(
                signingKey, SecurityAlgorithms.HmacSha256)
        };

        var handler = new JwtSecurityTokenHandler();
        var token = handler.CreateToken(descriptor);
        return handler.WriteToken(token);
    }
}

Keep secret keys out of source code. Use appsettings.json for development and environment variables or Azure Key Vault in production. The key should be at least 256 bits (32 bytes) for HMAC-SHA256 -- shorter keys will cause validation errors at startup.

Note: This example demonstrates self-issued JWTs, which is appropriate for some systems. Many production APIs delegate token issuance to an external identity provider -- such as Auth0, Azure AD, or Duende IdentityServer -- rather than issuing tokens directly. Evaluate which model fits your security and operational requirements before deciding.

The [Authorize] Attribute

The [Authorize] attribute is the simplest way to require authentication for a controller or individual action. Applied at the controller level, it protects all actions. Applied at the action level, it protects only that action. You can use [AllowAnonymous] on specific actions to opt them out of a controller-level [Authorize].

Role-based authorization is a quick entry point: [Authorize(Roles = "Admin")] restricts access to users whose token contains a Role claim with the value "Admin". Multiple roles can be specified with a comma-separated string -- [Authorize(Roles = "Admin,Manager")] -- and the check is an OR condition (the user needs at least one of the listed roles).

Role-based authorization works for simple scenarios, but it couples your code to specific string values and can become messy as access rules grow. Policy-based authorization is the idiomatic approach for anything beyond basic role checks.

Policy-Based Authorization

Policy-based authorization decouples the access rule definition from the endpoint. You define named policies in Program.cs that describe what a user must satisfy, and you reference policies by name in attributes or endpoint configuration. This keeps access logic centralized and testable.

AddAuthorizationBuilder is a modern, convenient fluent option added in .NET 8 -- it provides a fluent API for building policies without the verbose lambda callback syntax of AddAuthorization(options => ...). The traditional AddAuthorization(options => ...) style remains valid if you prefer it.

// Policy-based authorization setup with AddAuthorizationBuilder (.NET 10)
using Microsoft.AspNetCore.Authorization;

// In Program.cs, after AddAuthentication:
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"))
    .AddPolicy("SeniorDeveloperOrAbove", policy =>
        policy.RequireRole("Senior", "Lead", "Principal", "Admin"))
    .AddPolicy("VerifiedEmail", policy =>
        policy.RequireClaim("email_verified", "true"))
    .AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));

builder.Services.AddScoped<IAuthorizationHandler, MinimumAgeHandler>();

// Custom requirement
public sealed record MinimumAgeRequirement(int MinimumAge) : IAuthorizationRequirement;

// Custom handler
public sealed class MinimumAgeHandler
    : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        var birthDateClaim = context.User.FindFirst("birthdate");

        if (birthDateClaim is null ||
            !DateOnly.TryParse(birthDateClaim.Value, out var birthDate))
        {
            return Task.CompletedTask; // Fails silently -- requirement not met
        }

        var age = DateOnly.FromDateTime(DateTime.UtcNow).Year - birthDate.Year;
        if (age >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

// Applying the policy to a controller
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "SeniorDeveloperOrAbove")]
public sealed class AdminReportsController : ControllerBase
{
    // All actions require SeniorDeveloperOrAbove policy
}

Role-centric authorization works well for simple access models; for more granular permissions, claim-based or permission-based models are often cleaner.

Custom requirements and handlers are powerful because they can contain any logic -- database lookups, external service calls, complex claim expressions. The handler is registered in the DI container and can have any scoped or transient dependencies injected. This makes authorization logic fully testable in isolation. If you're building systems with complex domain rules, the mediator design pattern pairs well with authorization handlers for coordinating multiple concerns without tight coupling.

Resource-Based Authorization

Standard attribute-based authorization runs before the action body executes. That's fine for coarse-grained checks -- "only admins can access this endpoint." But sometimes you need to authorize against the actual resource being accessed -- "only the owner of this document can edit it." That check requires the resource to be loaded first.

Resource-based authorization uses IAuthorizationService.AuthorizeAsync injected into the controller or endpoint. You pass the current user, the resource, and the policy name, and the authorization service evaluates the requirement against both.

// Resource-based authorization in an action method
[ApiController]
[Route("api/[controller]")]
[Authorize] // Must be authenticated, but specific access checked per-resource
public sealed class DocumentsController : ControllerBase
{
    private readonly IDocumentService _documentService;
    private readonly IAuthorizationService _authorizationService;

    public DocumentsController(
        IDocumentService documentService,
        IAuthorizationService authorizationService)
    {
        _documentService = documentService;
        _authorizationService = authorizationService;
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, UpdateDocumentRequest request)
    {
        var document = await _documentService.GetByIdAsync(id);

        if (document is null)
        {
            return NotFound();
        }

        // Check if the current user is allowed to edit THIS document
        var authResult = await _authorizationService
            .AuthorizeAsync(User, document, "DocumentOwner");

        if (!authResult.Succeeded)
        {
            return Forbid(); // 403 -- authenticated but not authorized for this resource
        }

        var updated = await _documentService.UpdateAsync(id, request);
        return Ok(updated);
    }
}

The DocumentOwner policy would use a custom handler that checks whether HttpContext.User's subject claim matches the OwnerId field on the document entity. This pattern is essential for multi-tenant applications where row-level access control determines what each user can read and write.

Minimal API Authorization

Minimal APIs use the same asp.net core authentication middleware but express the configuration through extension methods rather than attributes. You call .RequireAuthorization() with no arguments for basic authentication checks, or pass a policy name for policy-based checks. .AllowAnonymous() opts a specific endpoint out of a group-level authorization requirement.

var protectedGroup = app.MapGroup("/api/protected")
    .RequireAuthorization(); // All endpoints require authentication

protectedGroup.MapGet("/profile", async (ClaimsPrincipal user) =>
    Results.Ok(new { Name = user.Identity?.Name, Claims = user.Claims.Select(c => new { c.Type, c.Value }) }));

protectedGroup.MapGet("/admin-only", async (ClaimsPrincipal user) =>
    Results.Ok("Admin content")).RequireAuthorization("AdminOnly"); // Additional policy

The ClaimsPrincipal can be injected directly as a parameter in minimal API handlers, which is a convenient shortcut when you need basic identity information without going through HttpContext.

Common Pitfalls

Several mistakes come up repeatedly in ASP.NET Core authentication setups. asp.net core authentication failures are often not authentication bugs at all -- they're configuration or middleware ordering mistakes. They're worth knowing in advance.

Forgetting to call UseAuthentication() before UseAuthorization() is the most common. Without UseAuthentication(), HttpContext.User is never populated, and all authorization checks fail silently -- or rather, they succeed for endpoints without [Authorize] and fail with 401 for endpoints that have it, but for the wrong reason. The fix is always to check middleware ordering.

ClockSkew is a subtle one. JWTs contain an expiration timestamp, but clocks between the token issuer and the validating server are rarely perfectly synchronized. TokenValidationParameters.ClockSkew defaults to 5 minutes, which means a token is considered valid for up to 5 minutes after its exp claim says it expired. This is usually fine. Problems appear when you set ClockSkew = TimeSpan.Zero for strict testing and then find that freshly issued tokens occasionally fail validation due to clock drift in distributed environments.

Not validating the audience is a security issue that's easy to overlook. If your API trusts any validly signed token -- regardless of which service it was issued for -- an attacker who obtains a valid token for one of your other services can use it against your API. Always set ValidateAudience = true and configure a unique audience value per API.

Conclusion

ASP.NET Core authentication gives you a solid, extensible foundation for securing your APIs. JWT bearer authentication covers the majority of Web API use cases: stateless, scalable, and straightforward to configure. Policy-based authorization keeps access rules maintainable as your application grows. Resource-based authorization handles the fine-grained scenarios where coarse policies aren't enough.

The patterns shown here -- full JWT setup, token issuance, policy definitions with custom requirements, and resource-based checks -- form the security layer of any serious .NET 10 API. Build on them incrementally, and you'll have a system that's both secure and easy to reason about.


Frequently Asked Questions

What is the difference between authentication and authorization in ASP.NET Core?

Authentication establishes who a user is. It reads the incoming credential -- typically a JWT in the Authorization: Bearer header -- validates it, and populates HttpContext.User with a ClaimsPrincipal representing the caller's identity. This happens in the UseAuthentication() middleware before your endpoint runs.

Authorization uses that established identity to decide what the user is allowed to do. It checks whether the ClaimsPrincipal meets the requirements of the [Authorize] attribute or policy applied to the endpoint. UseAuthorization() must run after UseAuthentication() because it needs the populated identity to make its decision.

The separation matters because they can fail independently. A request can be authenticated (valid token) but unauthorized (the user doesn't have the required role). Or unauthenticated (no token or invalid token) -- in which case the authorization middleware doesn't even get to evaluate the policy.

How do I refresh JWT tokens without requiring the user to log in again?

The standard approach is a refresh token pattern. When you issue a short-lived access token (typically 15 minutes to 1 hour), you also issue a long-lived refresh token (days to weeks) that is stored securely -- ideally in an HttpOnly cookie to prevent JavaScript access. When the access token expires, the client sends the refresh token to a dedicated endpoint, which validates it and issues a new access token.

The refresh token itself must be stored server-side (in a database) so it can be revoked. Storing only the refresh token hash is safer than storing the raw value. When a user logs out, you delete the refresh token from the database, which invalidates it on next use even if the access token hasn't expired yet.

This is more complex than single-token authentication, but it's the right trade-off for user-facing applications. For machine-to-machine scenarios, long-lived access tokens or client credentials flow may be more appropriate.

Can ASP.NET Core handle multiple authentication schemes at once?

Yes. You can register multiple authentication schemes and specify which endpoints use which scheme. The [Authorize(AuthenticationSchemes = "...")] attribute accepts a scheme name. This is useful when part of your API authenticates with JWT and another part uses API key authentication or cookie authentication.

For overlapping scenarios, you can use the AuthenticationSchemes property to require any one of multiple valid schemes. The authentication middleware will try each listed scheme in order and succeed if any of them validates successfully. This is common in APIs that serve both browser-based clients (cookies) and programmatic clients (JWT bearer).

Should I store JWT signing keys in appsettings.json?

No -- not in production. appsettings.json is often committed to version control, and a leaked signing key allows anyone to forge valid tokens for your API. Use environment variables or a secrets manager in production environments.

In development, the .NET Secret Manager (dotnet user-secrets) provides a convenient way to store sensitive configuration outside of the project directory. In production on Azure, Azure Key Vault is the standard solution. The key retrieval is transparent -- you configure the key vault reference in Program.cs, and the configuration system resolves the secret at startup through the same IConfiguration interface.

What claims should I include in a JWT token?

Include the minimum claims needed for your application's authorization decisions. The standard registered claims -- sub (subject/user ID), iat (issued at), exp (expiration), iss (issuer), aud (audience) -- should always be present. Beyond those, include only what your endpoints actually need: roles, a user tier identifier, verified status flags.

Avoid including sensitive data in claims. JWTs are Base64-encoded, not encrypted -- anyone who has the token can read the claims. The signature prevents tampering, but it doesn't hide the content. If you need to include sensitive information, use an opaque token (reference token) with a token introspection endpoint, or use JWT encryption (JWE) rather than plain JWT (JWS).

How does [AllowAnonymous] interact with [Authorize]?

[AllowAnonymous] is an absolute override -- it bypasses all authentication and authorization checks for the decorated action or controller, regardless of any [Authorize] attribute at a higher level. If a controller has [Authorize] and an action has [AllowAnonymous], the action is publicly accessible.

This matters for the login endpoint pattern. Your auth controller needs to be accessible without a token (obviously), so you apply [AllowAnonymous] to the login action even if the rest of the application requires authentication globally. The same applies to health check endpoints, public documentation endpoints, and similar open resources in an otherwise secured API.

How do I test JWT-protected endpoints in integration tests?

The recommended approach is to create a test JWT using the same JwtSecurityTokenHandler and the same signing key your test environment uses, then add it to the request Authorization header in WebApplicationFactory tests. You can register a custom test authentication scheme that bypasses token validation entirely and returns a predetermined ClaimsPrincipal -- this is faster and doesn't require managing keys in tests.

The test authentication scheme approach uses AddAuthentication("Test").AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(...) in the WebApplicationFactory configuration. The TestAuthHandler returns a fixed principal based on query parameters or headers you set per-test. This gives you full control over what identity the endpoint sees without coupling your test infrastructure to real JWT configuration. The DI concepts underlying this pattern are explored in detail in how DI containers use reflection internally.

How to Build An ASP.NET Core Web API: A Practical Beginner's Tutorial

Learn how to build an ASP.NET core web API! This tutorial for beginners will guide you through setting up the project to building the API endpoints.

ASP.NET Core Web API in .NET: The Complete Guide

Master ASP.NET Core Web API in .NET 10 -- learn request pipelines, routing, controllers, JWT authentication, error handling, and deployment strategies.

API Key Authentication Middleware In ASP NET Core - A How To Guide

Want to add API key authentication middleware into your ASP.NET Core application? Check out this article for a simple code example that shows you how!

An error has occurred. This application may no longer respond until reloaded. Reload