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:
- On login, return an access token and a long-lived, random refresh token (e.g. 7–30 days).
- Store a hash of the refresh token in the database, with its expiry and the user id.
- When the access token expires, the client calls
/api/auth/refreshwith the refresh token. - The server validates it, rotates it (issues a new refresh token and revokes the old one) and returns a new access token.
- 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
- Weak or short signing keys — use at least 256 bits of randomness for HS256, or RS256 with a key pair.
- Disabling lifetime, issuer or audience validation "to make it work".
- Long-lived access tokens without a revocation strategy.
- Accepting
alg: none— the default handler rejects it; don't override that. - Sensitive data in claims — payloads are readable.
- 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
JWT or cookie authentication?
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.