# JWT Authentication in ASP.NET Core: Complete Guide

> JWT tokens are the standard way to authenticate APIs. Here is a complete guide to setting up JWT authentication in ASP.NET Core - from token generation to validation, refresh tokens, and common security mistakes.

Published: 2026-07-04. Last updated: 2026-07-14. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/jwt-authentication-aspnetcore

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](https://milanjovanovic.tech/blogs/articles/jwt-authentication-aspnetcore/jwt-auth-flow.png)

## Setting Up JWT Authentication

Install the required package:

```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

Configure the authentication middleware:

```csharp
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:

```csharp
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](#refresh-tokens) below.

```csharp
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`):

```csharp
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.

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

public sealed record LoginResponse(string AccessToken, string RefreshToken);
```

```csharp
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:

```csharp
// 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:

```csharp
[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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/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](https://milanjovanovic.tech/blogs/articles/jwt-authentication-aspnetcore/refresh-token-rotation.png)

```csharp
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:

```csharp
public sealed record RefreshRequest(string RefreshToken);
```

```csharp
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`:

```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**](https://milanjovanovic.tech/blog/integrate-keycloak-with-aspnetcore-using-oauth-2) 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.
