JWT Authentication in ASP.NET Core Done Properly

JWT authentication in ASP.NET Core is one of those topics that looks simple on the surface but has plenty of ways to go wrong in production. Developers often copy a working code snippet, ship it, and only later discover they’ve left security gaps — improperly validated tokens, weak signing keys, missing claims checks, or refresh token logic that enables replay attacks. This guide covers JWT authentication in ASP.NET Core done properly: from setup and token generation to refresh tokens, revocation strategies, and common pitfalls to avoid. If you’re building a secure Web API with ASP.NET Core, this is required reading.
Table of Contents
- Why JWT Authentication in ASP.NET Core Is Commonly Misimplemented
- Installing the Required NuGet Packages
- Configuring JWT Authentication in Program.cs
- Storing JWT Settings Securely
- Generating JWT Tokens Correctly
- Implementing Refresh Token Logic
- Extracting the Principal from an Expired Access Token
- Role-Based Authorization with JWT
- Token Revocation Strategies
- Common Security Mistakes to Avoid
- Testing JWT Authentication
- Conclusion
Why JWT Authentication in ASP.NET Core Is Commonly Misimplemented
JSON Web Tokens (JWTs) are stateless, self-contained bearer tokens — and that’s both their strength and their risk. Because the server doesn’t store session state, it must trust whatever is in the token. If you don’t validate the token properly, an attacker can forge or manipulate it. The most critical validation steps are: verifying the signature, checking the expiry (exp claim), validating the issuer and audience, and ensuring the algorithm matches what you expect. The official Microsoft documentation on ASP.NET Core authentication provides a solid foundation, but the real-world implementation requires more care than the docs suggest.
Installing the Required NuGet Packages
Start by adding the JWT Bearer authentication package to your ASP.NET Core project:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerThis package integrates tightly with the built-in middleware pipeline. For token generation, you’ll also need System.IdentityModel.Tokens.Jwt, which is pulled in as a transitive dependency but can be referenced directly if needed.
Configuring JWT Authentication in Program.cs
In modern ASP.NET Core (minimal hosting model), all authentication configuration goes into Program.cs. The critical thing here is to be explicit about every validation parameter — don’t rely on defaults.
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["SecretKey"]!;
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey)),
ClockSkew = TimeSpan.Zero // Remove default 5-minute tolerance
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();Setting ClockSkew = TimeSpan.Zero is important. By default, ASP.NET Core adds a 5-minute leeway to token expiry — meaning an expired token can still work for 5 minutes. In security-sensitive applications, remove this tolerance entirely.
Storing JWT Settings Securely
Never hardcode your JWT secret key in appsettings.json. In development, use User Secrets. In production, use environment variables or a secrets manager. If you’re deploying to Azure, Azure Key Vault is the correct way to manage secrets in C# applications and integrates seamlessly with the ASP.NET Core configuration system.
Your appsettings.json should only contain non-secret configuration:
{
"JwtSettings": {
"Issuer": "https://yourdomain.com",
"Audience": "https://yourdomain.com",
"ExpiryMinutes": 15,
"RefreshTokenExpiryDays": 7
}
}The secret key should come from environment variables or a vault at runtime — not from source control.
Generating JWT Tokens Correctly
Encapsulate token generation in a dedicated service. This keeps your controllers thin and your token logic testable. A proper TokenService should generate both the access token and a cryptographically random refresh token.
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateAccessToken(ApplicationUser user, IList<string> roles)
{
var jwtSettings = _config.GetSection("JwtSettings");
var secretKey = _config["JwtSettings:SecretKey"]!;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
new Claim(JwtRegisteredClaimNames.Email, user.Email!),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64)
};
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var expiry = int.Parse(jwtSettings["ExpiryMinutes"]!);
var token = new JwtSecurityToken(
issuer: jwtSettings["Issuer"],
audience: jwtSettings["Audience"],
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(expiry),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
}Notice the inclusion of a Jti (JWT ID) claim — a unique identifier per token. This is essential for token revocation, as it lets you maintain a blocklist of invalidated token IDs without storing the full token. Also note HmacSha256 is used — never use None as an algorithm, which is a known attack vector.
Implementing Refresh Token Logic
Access tokens should have short lifespans (15–60 minutes). Refresh tokens are long-lived and stored server-side, allowing you to issue new access tokens without requiring the user to re-authenticate. This is where the JWT authentication in ASP.NET Core implementation gets more nuanced.
Refresh Token Entity
public class RefreshToken
{
public int Id { get; set; }
public string Token { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsRevoked { get; set; }
public string? ReplacedByToken { get; set; } // For token rotation
}Token Refresh Endpoint
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var principal = _tokenService.GetPrincipalFromExpiredToken(request.AccessToken);
if (principal is null)
return Unauthorized("Invalid access token");
var userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub);
var storedToken = await _refreshTokenRepository
.GetByTokenAsync(request.RefreshToken);
if (storedToken is null
|| storedToken.UserId != userId
|| storedToken.IsRevoked
|| storedToken.ExpiresAt < DateTime.UtcNow)
{
return Unauthorized("Invalid or expired refresh token");
}
var user = await _userManager.FindByIdAsync(userId!);
var roles = await _userManager.GetRolesAsync(user!);
var newAccessToken = _tokenService.GenerateAccessToken(user!, roles);
var newRefreshToken = _tokenService.GenerateRefreshToken();
// Rotate: revoke old, issue new
storedToken.IsRevoked = true;
storedToken.ReplacedByToken = newRefreshToken;
await _refreshTokenRepository.UpdateAsync(storedToken);
await _refreshTokenRepository.AddAsync(new RefreshToken
{
Token = newRefreshToken,
UserId = userId!,
ExpiresAt = DateTime.UtcNow.AddDays(
int.Parse(_config["JwtSettings:RefreshTokenExpiryDays"]!))
});
return Ok(new { AccessToken = newAccessToken, RefreshToken = newRefreshToken });
}Refresh token rotation is a best practice: each time a refresh token is used, it is revoked and replaced with a new one. This limits the blast radius if a refresh token is stolen — the attacker only has one use before the token is invalidated.
Extracting the Principal from an Expired Access Token
To validate a refresh request, you need to read claims from the expired access token without rejecting it due to expiry. Add this method to your TokenService:
public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
{
var secretKey = _config["JwtSettings:SecretKey"]!;
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = false, // Allow expired tokens here
ValidateIssuerSigningKey = true,
ValidIssuer = _config["JwtSettings:Issuer"],
ValidAudience = _config["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey))
};
var handler = new JwtSecurityTokenHandler();
try
{
var principal = handler.ValidateToken(token, validationParams, out var validatedToken);
if (validatedToken is not JwtSecurityToken jwtToken
|| !jwtToken.Header.Alg.Equals(
SecurityAlgorithms.HmacSha256,
StringComparison.InvariantCultureIgnoreCase))
{
return null;
}
return principal;
}
catch
{
return null;
}
}The algorithm check at the end is critical. It prevents an attacker from swapping the algorithm to none and bypassing signature validation entirely — a classic JWT attack.
Role-Based Authorization with JWT
Because role claims are embedded in the token, role-based authorization works out of the box once JWT authentication is configured. Combine this with broader .NET application security best practices for a layered defense strategy.
[Authorize(Roles = "Admin")]
[HttpGet("admin/dashboard")]
public IActionResult AdminDashboard()
{
return Ok("Welcome, Admin!");
}
[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile()
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
return Ok(new { UserId = userId });
}For more complex scenarios — like policy-based authorization with multiple requirements — ASP.NET Core’s built-in authorization middleware handles this without any changes to your JWT setup. You can also read about integrating Azure OpenID Connect in ASP.NET Core if you need federated identity on top of or instead of local JWT issuance.
Token Revocation Strategies
JWT’s stateless nature means there is no built-in way to revoke a token before it expires. Here are the practical approaches:
Short-Lived Access Tokens
The simplest mitigation is keeping access tokens short-lived (15 minutes or less). Even if a token is stolen, the attacker’s window is narrow.
Jti Blocklist
Store revoked Jti values in Redis or a database. On each request, check the token’s Jti against the blocklist. This adds a small latency overhead but provides true revocation capability. A custom IAuthorizationHandler or middleware can perform this check efficiently. Since well-structured ASP.NET Core APIs already use middleware for cross-cutting concerns, plugging in a token validation step fits naturally into that pattern.
Refresh Token Revocation
Since refresh tokens are stored server-side, they can be revoked at any time — on logout, password change, or suspicious activity detection. When a user logs out, mark both the current refresh token as revoked and optionally revoke all refresh tokens for that user.
Common Security Mistakes to Avoid
Several patterns consistently appear in insecure JWT implementations:
Storing JWTs in localStorage — This exposes your tokens to XSS attacks. Prefer httpOnly cookies for refresh tokens and in-memory storage for access tokens on the client side.
Using symmetric keys that are too short — Your secret key should be at least 256 bits (32 bytes) of entropy. A short or guessable key can be brute-forced.
Not validating the algorithm — Always check that the token was signed with the expected algorithm. Some older JWT libraries had vulnerabilities around the alg: none case.
Trusting claims without re-validating business rules — A role claim embedded in a token reflects the user’s role at token issuance time. If you revoke a role mid-session, the token still carries the old role until it expires. For sensitive role changes, combine short token lifespans with a role-check middleware or re-fetch from the database on critical operations.
Logging full tokens — Never log JWT values. They are bearer tokens — anyone with a copy has full access until expiry. This is especially important when reviewing ASP.NET Core health check and observability configurations to ensure request logs don’t capture Authorization headers.
Testing JWT Authentication
Write integration tests that exercise the full authentication flow — login, token validation, refresh, and revocation. Use WebApplicationFactory<T> in your test project and configure a test-specific JWT secret. Verify that requests with expired tokens return 401, requests with valid tokens return the expected response, and that revoked refresh tokens are rejected.
[Fact]
public async Task ExpiredToken_Returns401()
{
// Arrange: generate a token that is already expired
var token = GenerateExpiredTestToken();
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync("/api/profile");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}Automated testing of your auth layer is non-negotiable in production-grade applications. If you haven’t already, review our guide on unit testing in .NET with best practices and tools to build a solid testing foundation around your security code.
Conclusion
Implementing JWT authentication in ASP.NET Core correctly means going beyond the basic tutorial setup. The key pillars are strict token validation (all parameters, no defaults), short-lived access tokens, server-side refresh token management with rotation, algorithm enforcement, and a revocation strategy suited to your security requirements. WireFuture’s team builds production-grade ASP.NET Core development services with security baked in from day one — not bolted on after the fact. If you need a secure, scalable .NET backend for your next project, reach out to WireFuture to discuss your requirements.
WireFuture stands for precision in every line of code. Whether you're a startup or an established enterprise, our bespoke software solutions are tailored to fit your exact requirements.
No commitment required. Whether you’re a charity, business, start-up or you just have an idea – we’re happy to talk through your project.
Embrace a worry-free experience as we proactively update, secure, and optimize your software, enabling you to focus on what matters most – driving innovation and achieving your business goals.

