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();
}
}
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.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.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.
Mistake 2 — Refresh token cookie without Path restriction (sent to all API endpoints)
❌ Wrong — cookie without Path; browser sends refresh token to every API request unnecessarily.
✅ Correct — Path = "/api/auth"; cookie only accompanies auth endpoint requests.