JWT security in production requires attention to key management, token storage, revocation, and the overall Angular auth architecture. A secure JWT implementation is not just about generating and validating tokens — it is about the full lifecycle: where the secret is stored, where the token is stored on the client, how revocation works for logout, and how the Angular client manages token state. These production practices are what separate a functional authentication system from a secure one.
Signing Key Management
// ── Validate JWT settings at startup ──────────────────────────────────────
public class JwtSettings
{
[Required, MinLength(32)] // at least 256 bits
public string SecretKey { get; set; } = string.Empty;
[Required]
public string Issuer { get; set; } = string.Empty;
[Required]
public string Audience { get; set; } = string.Empty;
[Range(5, 1440)]
public int AccessTokenExpiryMinutes { get; set; } = 60;
[Range(1, 365)]
public int RefreshTokenExpiryDays { get; set; } = 30;
}
// builder.Services.AddOptions<JwtSettings>().ValidateDataAnnotations().ValidateOnStart();
// → Fails at startup if SecretKey is missing or too short — fast failure
// ── Token revocation via JTI denylist ─────────────────────────────────────
// On logout: add the access token's JTI to Redis with TTL matching the token's remaining lifetime
public class TokenRevocationService(IConnectionMultiplexer redis) : ITokenRevocationService
{
public async Task RevokeAsync(string jti, TimeSpan remaining)
{
var db = redis.GetDatabase();
await db.StringSetAsync($"revoked-jti:{jti}", "1", remaining);
}
public async Task<bool> IsRevokedAsync(string jti)
{
var db = redis.GetDatabase();
return await db.KeyExistsAsync($"revoked-jti:{jti}");
}
}
// ── JWT Bearer event — check denylist on every request ────────────────────
options.Events = new JwtBearerEvents
{
OnTokenValidated = async ctx =>
{
var jti = ctx.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti);
var revokeSvc = ctx.HttpContext.RequestServices
.GetRequiredService<ITokenRevocationService>();
if (jti is not null && await revokeSvc.IsRevokedAsync(jti))
{
ctx.Fail("Token has been revoked.");
}
}
};
TokenValidationParameters.IssuerSigningKeys during the transition, then remove the old key after all old tokens have expired.Complete Auth Controller
[ApiController]
[Route("api/auth")]
public class AuthController(
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IRefreshTokenRepository refreshTokenRepo,
ITokenRevocationService revokeService,
IOptions<JwtSettings> jwtOptions) : ControllerBase
{
[HttpPost("login"), AllowAnonymous] public async Task<IActionResult> Login([...]) { ... }
[HttpPost("refresh"), AllowAnonymous] public async Task<IActionResult> Refresh([...]) { ... }
// ── POST /api/auth/logout ────────────────────────────────────────────
[HttpPost("logout"), Authorize]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
{
// Revoke the current access token by JTI
var jti = User.FindFirstValue(JwtRegisteredClaimNames.Jti);
if (jti is not null)
{
var exp = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
var remaining = exp is not null
? DateTimeOffset.FromUnixTimeSeconds(long.Parse(exp)) - DateTimeOffset.UtcNow
: TimeSpan.FromMinutes(jwtOptions.Value.AccessTokenExpiryMinutes);
await revokeService.RevokeAsync(jti, remaining);
}
// Revoke the refresh token
await refreshTokenRepo.RevokeByTokenAsync(request.RefreshToken, ct);
return NoContent();
}
// ── GET /api/auth/me — current user profile ──────────────────────────
[HttpGet("me"), Authorize]
public async Task<ActionResult<UserProfileDto>> Me()
{
var user = await userManager.GetUserAsync(User);
return user is null ? NotFound() : Ok(user.ToProfileDto());
}
}
Common Mistakes
Mistake 1 — Storing JWT signing secret in appsettings.json (committed to source control)
❌ Critical — anyone with repo access can forge tokens for any user including admins.
✅ Correct — environment variable, User Secrets (dev), or Azure Key Vault (production).
Mistake 2 — No token revocation on logout (access token still valid after logout)
❌ Wrong — user clicks logout; frontend clears token; but the JWT is still valid on the server.
✅ Correct — on logout, add JTI to Redis denylist with TTL = remaining token lifetime.