AuthService uses JSON Web Tokens (JWT) for stateless authentication. Every API request with a JWT is validated to ensure:
The token was signed by this server
The issuer and audience match expected values
The token hasn’t expired
The signature algorithm is the expected one
JWT tokens consist of three parts: Header (algorithm + token type), Payload (claims/data), and Signature (cryptographic verification). They’re encoded as header.payload.signature in Base64URL format.
ValidateIssuerSigningKey = true,IssuerSigningKey = new SymmetricSecurityKey(key),
Purpose: Ensures the token was signed by this server and hasn’t been tampered with.
If an attacker modifies any part of the token (e.g., changing the user ID in the payload), the signature verification will fail. This prevents token forgery and tampering.
How it works:
Server generates signature: HMACSHA256(header + payload, secretKey)
Token includes this signature as the third part
On validation, server recalculates signature and compares
Purpose: Verifies the token was issued by this specific service.
Issuer validation is critical in microservice architectures where multiple services might issue tokens. It ensures a token from ServiceA can’t be used to access ServiceB.
Purpose: Checks that the current time is between nbf (not before) and exp (expiration) claims.Token payload includes:
{ "nbf": 1678901234, // Not valid before this Unix timestamp "exp": 1678902134, // Expires at this Unix timestamp (15 min later) "iat": 1678901234 // Issued at this timestamp}
Access tokens in AuthService have a 15-minute lifetime by default. This short duration limits the window of opportunity if a token is compromised.
Purpose: Removes the default 5-minute grace period for token expiration.
This is one of the most important security configurations in AuthService. By default, ASP.NET Core JWT validation allows tokens to be valid for 5 minutes after expiration to account for clock drift between servers.Setting ClockSkew = TimeSpan.Zero means: If the token says it expires at 2:00 PM, it’s invalid at 2:00:01 PM. No exceptions.
Why zero clock skew?
Default (5 min skew)
Zero skew
15-minute token = 20 minutes actual
15-minute token = 15 minutes actual
Stolen token usable for 5 extra minutes after “expiration”
Expired tokens immediately invalid
Acceptable for large distributed systems
Better security for single-service or tightly-synced systems
Trade-offs:
✅ Better security: Expired tokens are truly expired
✅ Predictable behavior: Expiration time matches reality
⚠️ Clock sync required: Servers must have accurate time (use NTP)
⚠️ No grace period: Users may need to refresh slightly more often
If you’re running in a distributed environment with potential clock drift, consider a small clock skew (1-2 minutes) instead of zero. For single-server or containerized deployments with NTP, zero is ideal.
Tokens are generated by the TokenService with all required claims:
Services/TokenService.cs
public string GenerateAccessToken(User user){ var jwtSection = _config.GetSection("Jwt"); var secretKey = jwtSection["SecretKey"]!; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email), new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) }; var expiryMinutes = int.Parse(jwtSection["AccessTokenExpiryMinutes"] ?? "15"); var token = new JwtSecurityToken( issuer: jwtSection["Issuer"], audience: jwtSection["Audience"], claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes(expiryMinutes), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token);}
Key claims:
sub (Subject): User ID - uniquely identifies the user
email: User’s email address
unique_name: Username for display
jti (JWT ID): Unique token identifier for revocation tracking
iat (Issued At): Timestamp when token was created
The jti claim is crucial for implementing token revocation. While JWTs are stateless, you can maintain a revocation list of JWT IDs that should be rejected even if otherwise valid.
Refresh tokens use token rotation for security. Each refresh invalidates the old refresh token and issues a new one. If an old refresh token is reused, the entire token family is revoked, preventing token replay attacks.
# ❌ NEVER commit to source control"Jwt": { "SecretKey": "my-super-secret-key-that-is-very-long-and-random"}# ✅ Use environment variables or secret managementexport JWT__SECRET_KEY="$(openssl rand -base64 64)"
The secret key is the foundation of JWT security. If compromised, attackers can generate valid tokens for any user. Use a cryptographically random key of at least 256 bits (32 bytes).
When manually validating expired tokens (e.g., for refresh flow):
Services/TokenService.cs
var handler = new JwtSecurityTokenHandler();var principal = handler.ValidateToken(token, validationParams, out var validatedToken);if (validatedToken is not JwtSecurityToken jwtToken || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.OrdinalIgnoreCase)){ return null;}
This prevents the “none” algorithm attack where an attacker removes the signature and sets the algorithm to “none”. Always explicitly check that the algorithm matches your expectations.
Cause: Token’s exp claim is in the past.Solution: Use refresh token to get a new access token.Prevention: Implement automatic refresh before expiration.
401 Unauthorized: Invalid signature
Cause: Token was signed with a different key, or payload was modified.Solution: Check that JWT secret key matches between token generation and validation.Common mistake: Different secrets in development vs. production.
401 Unauthorized: Invalid issuer
Cause: Token’s iss claim doesn’t match ValidIssuer.Solution: Ensure configuration matches:
"Jwt": { "Issuer": "AuthService", // Must match exactly "Audience": "AuthServiceUsers"}
401 Unauthorized: Invalid audience
Cause: Token’s aud claim doesn’t match ValidAudience.Solution: Check that the token was generated for this API.