JWT Security Best Practices — Secrets, Storage and Revocation

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.");
        }
    }
};
Note: JTI-based revocation via Redis adds one Redis read per authenticated request. This is the cost of immediate revocation — without it, logged-out users retain access until the access token expires naturally. For most applications, the trade-off is acceptable: Redis reads are fast (<1ms), and the security benefit (immediate logout) is significant. If you use short-lived access tokens (15 minutes), the window of vulnerability after logout without revocation is small — you can skip revocation and accept the small window.
Tip: For the Angular architecture, use a layered storage approach: the access token lives in memory (a BehaviorSubject in an AuthService) — it is lost on page refresh but inaccessible to XSS. The refresh token lives in an HttpOnly cookie — inaccessible to JavaScript, automatically sent to the refresh endpoint. On page load, the Angular app silently calls the refresh endpoint (the HttpOnly cookie is sent automatically) to get a new access token. This gives you the XSS protection of HttpOnly cookies with the CSRF protection of in-memory tokens.
Warning: When rotating signing keys (replacing an old secret with a new one), have a transition period where the API accepts tokens signed by both the old and new keys. Without a transition, users with tokens signed by the old key are immediately logged out when the new key is deployed — a poor user experience. Configure multiple signing keys in 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.

🧠 Test Yourself

A user logs out. The 60-minute access token still has 45 minutes of validity. Without JTI revocation, can the token still be used to call protected API endpoints?