JWT Authentication in ASP.NET Core: Complete Guide

JWT Authentication in ASP.NET Core: Complete Guide

7 min read··Updated ·

aspnetcoreauthenticationdotnetsecurity

JWT authentication in ASP.NET Core takes one package and a bit of configuration: install Microsoft.AspNetCore.Authentication.JwtBearer, configure token validation in AddJwtBearer, and issue short-lived access tokens paired with rotating refresh tokens. But there's a gap between "it works" and "it's secure": weak keys, eternal tokens, and skipped validation checks are all one config line away.

This guide covers the full setup: token generation, validation, refresh tokens, and the configuration mistakes that weaken an otherwise sound design. If another identity provider issues the token, use the stricter third-party JWT validation checklist.

What Is JWT Authentication?

JWT (JSON Web Token) is a compact, URL-safe token format used to represent claims between two parties. It's the most common way to authenticate REST APIs.

The flow:

  1. Client sends credentials (username + password)
  2. Server validates credentials and generates a JWT
  3. Client includes the JWT in the Authorization header of subsequent requests
  4. Server validates the JWT on every request
JWT authentication sequence: the client posts credentials, the API validates them and returns a signed access token, and the client sends that token on later requests for the API to validate

Setting Up JWT Authentication

Install the required package:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Configure the authentication middleware:

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;

        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:SecretKey"]!)),
            ClockSkew = TimeSpan.Zero  // Disable the default 5-minute grace period
        };
    });

builder.Services.AddAuthorization();

Note the MapInboundClaims = false line. By default, the JWT handler silently renames standard claims to legacy XML claim type URIs, so sub arrives as ClaimTypes.NameIdentifier and FindFirst(JwtRegisteredClaimNames.Sub) returns null. Disabling the mapping keeps claims exactly as they appear in the token, and it is the number one JWT gotcha I see in real projects.

Add the middleware in the correct order:

app.UseAuthentication();
app.UseAuthorization();

Generating JWT Tokens

Create a token provider service that issues both tokens: a short-lived JWT access token and a random refresh token persisted to the database. The RefreshToken entity is defined in the refresh tokens section below.

public sealed class TokenProvider
{
    private readonly IConfiguration _configuration;
    private readonly AppDbContext _dbContext;

    public TokenProvider(IConfiguration configuration, AppDbContext dbContext)
    {
        _configuration = configuration;
        _dbContext = dbContext;
    }

    public (string AccessToken, string RefreshToken) GenerateTokens(User user)
    {
        var accessToken = GenerateAccessToken(user);

        var refreshToken = new RefreshToken
        {
            Id = Guid.NewGuid(),
            UserId = user.Id,
            Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)),
            ExpiresAt = DateTime.UtcNow.AddDays(7),
            CreatedAt = DateTime.UtcNow
        };

        _dbContext.RefreshTokens.Add(refreshToken);

        return (accessToken, refreshToken.Token);
    }

    private string GenerateAccessToken(User user)
    {
        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Email, user.Email),
            new(JwtRegisteredClaimNames.Name, user.FullName),
            new("permission", "orders:read"),
            new("permission", "orders:write")
        };

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

        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"]!));

        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddMinutes(30),
            SigningCredentials = credentials,
            Issuer = _configuration["Jwt:Issuer"],
            Audience = _configuration["Jwt:Audience"]
        };

        return new JsonWebTokenHandler().CreateToken(tokenDescriptor);
    }
}

I'm using JsonWebTokenHandler from Microsoft.IdentityModel.JsonWebTokens here, not the legacy JwtSecurityTokenHandler. It's the same handler ASP.NET Core uses to validate tokens since .NET 8, and it ships transitively with the JwtBearer package.

Register the provider (scoped, because it uses the DbContext):

builder.Services.AddScoped<TokenProvider>();

The Login Endpoint

The login endpoint returns both tokens. The access token authenticates requests, and the refresh token is the entry point for the refresh flow covered below.

public sealed record LoginRequest(string Email, string Password);

public sealed record LoginResponse(string AccessToken, string RefreshToken);
app.MapPost("/api/auth/login", async (
    LoginRequest request,
    IUserRepository userRepository,
    IPasswordHasher passwordHasher,
    TokenProvider tokenProvider,
    AppDbContext dbContext) =>
{
    var user = await userRepository.GetByEmailAsync(request.Email);

    if (user is null || !passwordHasher.Verify(request.Password, user.PasswordHash))
    {
        return Results.Unauthorized();
    }

    var (accessToken, refreshToken) = tokenProvider.GenerateTokens(user);

    await dbContext.SaveChangesAsync();

    return Results.Ok(new LoginResponse(accessToken, refreshToken));
});

The SaveChangesAsync call persists the refresh token that GenerateTokens added to the DbContext.

Security note: Always return the same error for "user not found" and "wrong password." This prevents user enumeration.

Protecting Endpoints

Use RequireAuthorization() on Minimal API endpoints:

// Requires any authenticated user
app.MapGet("/api/orders", GetOrders)
    .RequireAuthorization();

// Requires specific role
app.MapPost("/api/orders", CreateOrder)
    .RequireAuthorization(policy => policy.RequireRole("Admin", "Manager"));

// Requires specific claim
app.MapDelete("/api/orders/{id}", DeleteOrder)
    .RequireAuthorization(policy => policy.RequireClaim("permission", "orders:delete"));

Or use the [Authorize] attribute on controllers:

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [Authorize(Roles = "Admin")]
    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(Guid id) { ... }
}

Accessing User Claims

Get the current user's information from the JWT claims:

public sealed class UserContext : IUserContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public UserContext(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public Guid UserId => Guid.Parse(
        _httpContextAccessor.HttpContext?.User
            .FindFirst(JwtRegisteredClaimNames.Sub)?.Value
        ?? throw new UnauthorizedAccessException());

    public string Email =>
        _httpContextAccessor.HttpContext?.User
            .FindFirst(JwtRegisteredClaimNames.Email)?.Value
        ?? throw new UnauthorizedAccessException();

    public bool IsAuthenticated =>
        _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
}

Looking up JwtRegisteredClaimNames.Sub only works because we set MapInboundClaims = false earlier. With the default mapping, sub would arrive as ClaimTypes.NameIdentifier and UserId would throw for every authenticated user.

Register it:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserContext, UserContext>();

Inject IUserContext into your Application layer to access the current user without depending on ASP.NET Core. I covered this abstraction in depth in getting the current user in Clean Architecture.

For authorization beyond simple role checks, see my guide to claims-based authorization.

Refresh Tokens

Access tokens should be short-lived (15-30 minutes). Use refresh tokens for extended sessions.

Refresh token rotation flow: a presented refresh token is checked for validity, an invalid one returns 401, and a valid one is revoked before a new access and refresh token pair is issued
public sealed class RefreshToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string Token { get; set; }
    public DateTime ExpiresAt { get; set; }
    public bool IsRevoked { get; set; }
    public DateTime CreatedAt { get; set; }
}

This is the entity that TokenProvider.GenerateTokens stores on every login. The client sends the refresh token back to exchange it for a new token pair:

public sealed record RefreshRequest(string RefreshToken);
app.MapPost("/api/auth/refresh", async (
    RefreshRequest request,
    AppDbContext dbContext,
    TokenProvider tokenProvider,
    IUserRepository userRepository) =>
{
    var storedToken = await dbContext.RefreshTokens
        .FirstOrDefaultAsync(r =>
            r.Token == request.RefreshToken &&
            !r.IsRevoked &&
            r.ExpiresAt > DateTime.UtcNow);

    if (storedToken is null)
    {
        return Results.Unauthorized();
    }

    // Rotate the refresh token
    storedToken.IsRevoked = true;

    var user = await userRepository.GetByIdAsync(storedToken.UserId);
    var (accessToken, newRefreshToken) = tokenProvider.GenerateTokens(user!);

    await dbContext.SaveChangesAsync();

    return Results.Ok(new LoginResponse(accessToken, newRefreshToken));
});

Key security practices:

  • Rotate refresh tokens on every use (issue a new one, revoke the old)
  • Store refresh tokens in the database (not just in memory)
  • Set a reasonable expiration (7-14 days)
  • Revoke all refresh tokens when the user changes their password

Rotation also lets you detect stolen tokens: if a revoked token is ever presented again, someone replayed it, and you should revoke the whole session family. I dig into that mechanism in refresh token rotation in ASP.NET Core.

Configuration

Store JWT settings in appsettings.json:

{
  "Jwt": {
    "Issuer": "https://myapp.com",
    "Audience": "https://myapp.com",
    "SecretKey": "your-256-bit-secret-key-here-minimum-32-chars"
  }
}

For production, use a proper secret management solution - not appsettings.json.

Common Security Mistakes

  1. Weak signing keys. Use at least 256-bit keys for HMAC. Better: use RSA keys.

  2. Storing tokens in localStorage. Vulnerable to XSS. Use HttpOnly cookies for web applications.

  3. Long-lived access tokens. Keep them short (15-30 minutes). Use refresh tokens for longer sessions.

  4. Not validating all token properties. Always validate issuer, audience, lifetime, and signing key.

  5. Setting ClockSkew too high. The default 5-minute clock skew means expired tokens are valid for 5 extra minutes. Set it to TimeSpan.Zero and handle clock synchronization at the infrastructure level.

  6. Including sensitive data in the payload. JWTs are signed but not encrypted by default. Don't put passwords, SSNs, or API keys in claims.

Summary

The safe default: proper validation, short-lived access tokens, and rotated refresh tokens.

JWT authentication in ASP.NET Core is straightforward:

  1. Configure AddJwtBearer with proper validation and MapInboundClaims = false
  2. Create a TokenProvider that generates access and refresh tokens
  3. Use RequireAuthorization() on endpoints
  4. Implement refresh token rotation for extended sessions
  5. Store secrets properly and keep access tokens short-lived

For more advanced scenarios, consider using an identity provider like Keycloak instead of managing JWTs yourself.


Frequently Asked Questions

What is a JWT?

A JSON Web Token is a compact, signed token containing claims about a user. The server validates the signature on every request, so no session state is needed. JWTs are signed, not encrypted, so anyone can read the payload.

How long should a JWT access token live?

Keep access tokens short-lived, typically 15 to 30 minutes. Pair them with refresh tokens stored server-side so sessions can continue without re-login and can be revoked when needed.

Where should I store JWTs in a web application?

Avoid localStorage, which any XSS payload can read. HttpOnly cookies are safer for browser apps, though they require CSRF protection. Mobile and desktop clients can use platform secure storage.

Why set ClockSkew to zero in ASP.NET Core JWT validation?

The default 5-minute clock skew means an expired token is still accepted for up to 5 extra minutes. Setting ClockSkew to TimeSpan.Zero enforces exact expiration, assuming your servers use synchronized clocks.

Should I build JWT auth myself or use an identity provider?

For anything beyond a simple API, an identity provider like Keycloak, Auth0, or Microsoft Entra ID is usually the better choice. You get password policies, MFA, token rotation, and revocation without maintaining that security-critical code yourself.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.