ASP.NET Core JWT Setup — Issuing Tokens on Login

The ASP.NET Core JWT authentication pipeline has three parts: configuration (telling the middleware how to validate tokens), the login endpoint (validating credentials and issuing tokens), and the token structure (what claims go in the JWT). Getting all three right produces a secure, stateless authentication system where the Angular app can verify the user’s identity and roles without a database round-trip on every request.

JWT Setup and AuthController

// ── appsettings.json — JWT configuration ──────────────────────────────────
// "Jwt": {
//   "Key":              "your-256-bit-secret-key-here-must-be-long",
//   "Issuer":           "https://api.blogapp.com",
//   "Audience":         "https://blogapp.com",
//   "AccessTokenMinutes": 15,
//   "RefreshTokenDays":   30
// }

// ── Program.cs — JWT middleware ───────────────────────────────────────────
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        var jwt = builder.Configuration.GetSection("Jwt");
        opts.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),
        };
    });

// ── AuthController ─────────────────────────────────────────────────────────
[ApiController, Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly SignInManager<ApplicationUser> _signIn;
    private readonly ITokenService _tokenService;
    private readonly IRefreshTokenRepository _refreshTokens;

    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
    {
        var user = await _userManager.FindByEmailAsync(request.Email);
        if (user is null || !user.IsActive)
            return Unauthorized(new { message = "Invalid credentials." });

        var result = await _signIn.CheckPasswordSignInAsync(user, request.Password, true);
        if (!result.Succeeded)
            return Unauthorized(new { message = "Invalid credentials." });

        var roles       = await _userManager.GetRolesAsync(user);
        var accessToken = _tokenService.GenerateAccessToken(user, roles);
        var refreshToken = await _refreshTokens.CreateAsync(user.Id, ct);

        // Refresh token in httpOnly cookie — Angular cannot read it
        Response.Cookies.Append("refreshToken", refreshToken, new CookieOptions
        {
            HttpOnly  = true,
            Secure    = true,
            SameSite  = SameSiteMode.Lax,
            Expires   = DateTimeOffset.UtcNow.AddDays(30),
            Path      = "/api/auth",   // cookie only sent to auth endpoints
        });

        return Ok(new AuthResponse(
            AccessToken: accessToken,
            ExpiresIn:   15 * 60,          // seconds
            DisplayName: user.DisplayName,
            Roles:       roles.ToList()
        ));
    }

    [HttpPost("refresh")]
    public async Task<IActionResult> Refresh(CancellationToken ct)
    {
        var cookieToken = Request.Cookies["refreshToken"];
        if (string.IsNullOrEmpty(cookieToken))
            return Unauthorized();

        var tokenHash = Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes(cookieToken)));

        var stored = await _refreshTokens.FindByHashAsync(tokenHash, ct);
        if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
            return Unauthorized(new { message = "Refresh token expired or invalid." });

        var user   = await _userManager.FindByIdAsync(stored.UserId);
        var roles  = await _userManager.GetRolesAsync(user!);
        var newAccessToken = _tokenService.GenerateAccessToken(user!, roles);

        // Rotate the refresh token
        await _refreshTokens.RevokeAsync(stored.Id, ct);
        var newRefreshToken = await _refreshTokens.CreateAsync(stored.UserId, ct);
        Response.Cookies.Append("refreshToken", newRefreshToken, new CookieOptions
            { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
              Expires = DateTimeOffset.UtcNow.AddDays(30), Path = "/api/auth" });

        return Ok(new AuthResponse(newAccessToken, 15 * 60,
                                   user!.DisplayName, roles.ToList()));
    }

    [HttpPost("logout"), Authorize]
    public async Task<IActionResult> Logout(CancellationToken ct)
    {
        var cookieToken = Request.Cookies["refreshToken"];
        if (!string.IsNullOrEmpty(cookieToken))
            await _refreshTokens.RevokeByTokenAsync(cookieToken, ct);

        Response.Cookies.Delete("refreshToken", new CookieOptions { Path = "/api/auth" });
        return NoContent();
    }
}
Note: Setting the refresh token cookie with Path = "/api/auth" restricts the cookie to only be sent with requests to /api/auth endpoints. Without this path restriction, the browser sends the refresh token cookie with every request to the API — including public endpoints like /api/posts. Restricting the path is a security improvement: the refresh token is only transmitted when needed (to the refresh and logout endpoints), reducing its exposure window.
Tip: Set ClockSkew = TimeSpan.FromSeconds(30) on the JWT validation parameters to allow for minor clock differences between servers. Without clock skew tolerance, a token issued at 14:59:59 on Server A might be rejected by Server B with a clock reading 15:00:01 as “already expired” (the default ValidateLifetime check is strict). 30 seconds is a common and safe skew value — small enough to not meaningfully extend token validity but large enough to handle clock drift.
Warning: Never put the JWT signing key in appsettings.json committed to source control. The key is a secret — anyone with the key can forge tokens. Store it in environment variables (Jwt__Key for ASP.NET Core’s nested config naming), Azure Key Vault, or a secrets manager. In development, use dotnet user-secrets set "Jwt:Key" "your-dev-key". Generate a proper key: openssl rand -base64 32 produces a 256-bit random key suitable for HS256 JWT signing.

Common Mistakes

Mistake 1 — JWT signing key in source control (security breach)

❌ Wrong — "Key": "mysecretkey" in appsettings.json committed to Git; anyone with repo access can forge tokens.

✅ Correct — key in environment variables or Azure Key Vault; never in committed configuration files.

❌ Wrong — cookie without Path; browser sends refresh token to every API request unnecessarily.

✅ Correct — Path = "/api/auth"; cookie only accompanies auth endpoint requests.

🧠 Test Yourself

The JWT validation has ValidateLifetime = true and ClockSkew = TimeSpan.Zero. A token with exp = now arrives exactly at its expiry second. Is it accepted?