Token Generation — Issuing Access and Refresh Tokens

📋 Table of Contents
  1. JWT Token Service
  2. Common Mistakes

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(),
    });
}
Note: The Jti (JWT ID) claim is a unique identifier for this specific token. It is used for token revocation — when you want to immediately invalidate a token (on logout or security event), you store the JTI in a Redis denylist. On each request, the JWT validation checks whether the token’s JTI is in the denylist. Without JTI-based revocation, the only way to invalidate a JWT is to wait for it to expire naturally.
Tip: Use 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.
Warning: Never return the refresh token in the response body if you can set it as an 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.

🧠 Test Yourself

Why use two tokens (short-lived access token + long-lived refresh token) instead of one long-lived access token?