Articles › ASP.NET Core

JWT Authentication and Authorization in ASP.NET Core

Implement secure JWT authentication in ASP.NET Core step by step: issuing tokens, validating them, roles and policies, refresh tokens and the security mistakes to avoid.

Most modern APIs — especially those consumed by React, mobile apps or other services — authenticate with JSON Web Tokens (JWT). In this guide we'll implement JWT authentication in ASP.NET Core from scratch, add role- and policy-based authorization, discuss refresh tokens, and cover the security details that tutorials often skip.

Authentication vs authorization

  • Authentication answers who are you? — verifying identity (login).
  • Authorization answers what are you allowed to do? — checking permissions.

In ASP.NET Core these are two separate middlewares, and the order matters: UseAuthentication() must come before UseAuthorization().

What is a JWT?

A JWT is a compact, signed string made of three Base64URL parts separated by dots:

header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJBZG1pbiJ9.Xb5...
  • Header — the signing algorithm (HS256, RS256…).
  • Payload — the claims: user id, name, roles, expiry (exp), issuer (iss), audience (aud).
  • Signature — proves the token was issued by your server and hasn't been modified.

Warning: The payload is encoded, not encrypted. Anyone can decode it at jwt.io. Never put passwords, national IDs or other secrets in claims.

Because the token is self-contained and signed, the server can validate it without a database lookup — which is why JWTs scale well.

1. Install and configure

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

appsettings.json:

{
  "Jwt": {
    "Issuer": "MyApi",
    "Audience": "MyApiClients",
    "Key": "a-very-long-random-secret-of-at-least-32-bytes!!",
    "AccessTokenMinutes": 30
  }
}

Tip: Never commit real keys. Use User Secrets in development and environment variables or a secret store (Azure Key Vault, etc.) in production.

Program.cs:

var jwt = builder.Configuration.GetSection("Jwt");

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwt["Issuer"],
            ValidAudience = jwt["Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!)),
            ClockSkew = TimeSpan.FromSeconds(30)   // default is 5 minutes
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

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

2. Issue tokens

public class TokenService(IConfiguration configuration)
{
    public string CreateAccessToken(User user)
    {
        var jwt = configuration.GetSection("Jwt");
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(ClaimTypes.Name, user.UserName),
        };
        claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));

        var token = new JwtSecurityToken(
            issuer: jwt["Issuer"],
            audience: jwt["Audience"],
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(jwt.GetValue<int>("AccessTokenMinutes")),
            signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

3. The login endpoint

[ApiController]
[Route("api/auth")]
public class AuthController(AppDbContext db, TokenService tokens, IPasswordHasher<User> hasher) : ControllerBase
{
    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest request)
    {
        var user = await db.Users.Include(u => u.Roles)
            .FirstOrDefaultAsync(u => u.UserName == request.UserName);

        if (user is null ||
            hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password) == PasswordVerificationResult.Failed)
        {
            return Unauthorized(new { message = "Invalid username or password." });
        }

        return Ok(new { accessToken = tokens.CreateAccessToken(user) });
    }
}

Two important details:

  • Hash passwords with a slow, salted algorithm (PasswordHasher<T>, PBKDF2, BCrypt or Argon2) — never store or compare plain text.
  • Return the same error for "user not found" and "wrong password" so attackers can't enumerate valid usernames.

4. Protect endpoints

[Authorize]                       // any authenticated user
[HttpGet("profile")]
public IActionResult Profile()
{
    var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
              ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
    return Ok(new { userId, name = User.Identity!.Name });
}

[Authorize(Roles = "Admin")]       // role-based
[HttpDelete("users/{id:int}")]
public IActionResult DeleteUser(int id) => NoContent();

[AllowAnonymous]                   // opt out on a protected controller
[HttpGet("public")]
public IActionResult Public() => Ok();

The client sends the token in the Authorization header:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

5. Policy-based authorization

Roles get messy as permissions grow. Policies describe requirements by name:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanManageArticles", p => p.RequireRole("Admin", "Editor"));
    options.AddPolicy("VerifiedEmail", p => p.RequireClaim("email_verified", "true"));
    options.AddPolicy("Adult", p => p.RequireAssertion(ctx =>
        int.TryParse(ctx.User.FindFirstValue("age"), out var age) && age >= 18));
});

[Authorize(Policy = "CanManageArticles")]
public IActionResult Publish(int id) => Ok();

For resource-based rules (e.g. "only the author can edit this post"), implement an AuthorizationHandler<TRequirement, TResource> and call IAuthorizationService.AuthorizeAsync(User, post, "EditPolicy").

6. Refresh tokens

Access tokens should be short-lived (5–30 minutes): if one leaks, the damage window is small. To avoid forcing users to log in constantly, issue a refresh token alongside it:

  1. On login, return an access token and a long-lived, random refresh token (e.g. 7–30 days).
  2. Store a hash of the refresh token in the database, with its expiry and the user id.
  3. When the access token expires, the client calls /api/auth/refresh with the refresh token.
  4. The server validates it, rotates it (issues a new refresh token and revokes the old one) and returns a new access token.
  5. If a revoked refresh token is ever used again, assume theft and revoke the whole token family.
public static string CreateRefreshToken() =>
    Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));

Where should the browser store tokens?

Storage XSS risk CSRF risk Notes
localStorage high — any injected script can read it none simple; fine for low-risk admin panels with strict XSS hygiene
Memory (JS variable) lower none lost on refresh; pair with a refresh-token cookie
HttpOnly + Secure + SameSite cookie not readable by JS needs SameSite / anti-forgery the most robust option for browser apps

For high-security apps, keep the refresh token in an HttpOnly cookie and the access token in memory.

Common security mistakes

  1. Weak or short signing keys — use at least 256 bits of randomness for HS256, or RS256 with a key pair.
  2. Disabling lifetime, issuer or audience validation "to make it work".
  3. Long-lived access tokens without a revocation strategy.
  4. Accepting alg: none — the default handler rejects it; don't override that.
  5. Sensitive data in claims — payloads are readable.
  6. Not using HTTPS — tokens are bearer credentials; whoever holds one is "you".

Summary

JWT authentication in ASP.NET Core takes three steps: configure the JwtBearer handler with strict validation, issue signed tokens with meaningful claims after verifying hashed passwords, and protect endpoints with [Authorize], roles and policies. Keep access tokens short-lived, rotate refresh tokens, store secrets safely and always use HTTPS.

FAQ

For server-rendered sites on a single domain, cookie authentication is simpler and very secure. JWTs shine for APIs consumed by SPAs, mobile apps and other services.

How do I log a user out with JWT?

Delete the token on the client and revoke the refresh token on the server. For instant revocation of access tokens, keep them short-lived or maintain a deny-list of token ids (jti).

HS256 or RS256?

HS256 uses one shared secret — fine when the same app issues and validates tokens. RS256 uses a private/public key pair, so other services can validate tokens without being able to issue them.