The token service is responsible for creating signed JWTs with the appropriate claims and expiry. It is a pure infrastructure concern — it takes user data in and returns tokens out — with no database access or business logic. A separate refresh token (an opaque random string stored in the database) allows the Angular client to obtain a new short-lived access token without re-entering credentials. Together, short-lived access tokens (15–60 minutes) and long-lived refresh tokens (7–30 days) provide security and convenience.
JWT Token Service
// ── appsettings.json (values from environment/secrets in production) ──────
// "JwtSettings": {
// "SecretKey": "use-secrets-or-env-var-never-commit",
// "Issuer": "https://blogapp.com",
// "Audience": "https://blogapp.com",
// "AccessTokenExpiryMinutes": 60,
// "RefreshTokenExpiryDays": 30
// }
public class JwtTokenService(IOptions<JwtSettings> opts) : ITokenService
{
private readonly JwtSettings _settings = opts.Value;
public string GenerateAccessToken(ApplicationUser user, IList<string> roles)
{
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey));
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // unique token ID
new(JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
};
// Add role claims — each role is a separate claim
foreach (var role in roles)
claims.Add(new Claim(ClaimTypes.Role, role));
// Add custom claims
claims.Add(new Claim("displayName", user.DisplayName ?? user.Email!));
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpiryMinutes),
signingCredentials: signingCredentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public RefreshToken GenerateRefreshToken(string userId)
{
// Opaque random string — not a JWT; stored in database
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
return new RefreshToken
{
Token = token,
UserId = userId,
ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenExpiryDays),
CreatedAt = DateTime.UtcNow,
};
}
}
// ── Login endpoint ────────────────────────────────────────────────────────
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthResponse>> Login(
LoginRequest request, CancellationToken ct)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null || !await _userManager.CheckPasswordAsync(user, request.Password))
return Unauthorized(new { message = "Invalid email or password." });
if (await _userManager.IsLockedOutAsync(user))
return StatusCode(423, new { message = "Account is locked." });
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
var refreshToken = _tokenService.GenerateRefreshToken(user.Id);
await _refreshTokenRepo.AddAsync(refreshToken, ct);
return Ok(new AuthResponse
{
AccessToken = accessToken,
RefreshToken = refreshToken.Token,
ExpiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes),
User = user.ToProfileDto(),
});
}
IOptions<JwtSettings> with a strongly-typed settings class for all JWT configuration. Register: builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings")); builder.Services.AddOptions<JwtSettings>().ValidateDataAnnotations().ValidateOnStart(); — this validates required settings at startup, failing fast if the signing key is missing rather than failing on the first authenticated request.HttpOnly cookie instead. A refresh token in the response body is accessible to JavaScript, making it vulnerable to XSS. An HttpOnly cookie is inaccessible to JavaScript — XSS cannot steal it. For Angular SPAs, consider storing the refresh token in an HttpOnly cookie (SameSite=Strict) while returning the access token in the response body for Angular’s in-memory storage.Common Mistakes
Mistake 1 — Long-lived access tokens (hours or days) instead of short-lived + refresh
❌ Wrong — 24-hour access token; stolen token gives attacker access for the full day.
✅ Correct — 15–60 minute access tokens; long-lived refresh tokens with rotation for convenience.
Mistake 2 — Not storing the Jti claim (cannot revoke tokens without it)
❌ Wrong — no Jti in token; no way to invalidate a specific token before its expiry.
✅ Correct — always include a unique Jti; store it in a denylist on logout/revocation.