Token Validation
Token validation is the process of verifying that an incoming JWT is authentic, unexpired, and carries the expected claims. Every API request and inter-service call passes through token validation. This page documents the validation logic, error handling, and integration patterns.
Validation Pipeline
When a JWT arrives in an HTTP request, it passes through a multi-stage validation pipeline:
| Stage | Check | Failure Result |
|---|---|---|
| 1. Header extraction | Authorization header present and formatted as Bearer <token> | 401 Unauthorized |
| 2. Security filter | Input validation on headers, query params, URI | 400 Bad Request |
| 3. Signature verification | Token is signed by the platform's secret key | 401 Invalid signature |
| 4. Expiration check | Token has not passed its exp claim | 401 Token expired |
| 5. Issuer verification | iss claim matches expected issuer | 401 Invalid token |
| 6. Type validation | type claim matches expected token type for the endpoint | 401 Wrong token type |
| 7. Revocation check | Token jti is not in the revocation blacklist | 401 Token revoked |
| 8. Tenant extraction | tenant_id claim is present and valid | 403 Forbidden |
Core Validation Logic
The JwtTokenValidator class provides the core validation implementation:
// From JwtTokenValidator.java
public class JwtTokenValidator {
private final SecretKey signingKey;
private final String expectedIssuer;
private final JwtParser parser;
public JwtTokenValidator(String secret, String expectedIssuer) {
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expectedIssuer = expectedIssuer;
this.parser = Jwts.parser()
.verifyWith(signingKey)
.requireIssuer(expectedIssuer)
.build();
}The parser is configured once at construction time with:
- The signing key for signature verification
- The expected issuer for issuer validation
- JJWT's built-in expiration checking (enabled by default)
General Validation
The validate() method performs signature verification, expiration checking, and issuer validation in a single call:
public ValidationResult validate(String token) {
try {
Jws<Claims> jws = parser.parseSignedClaims(token);
Claims claims = jws.getPayload();
return ValidationResult.success(claims);
} catch (ExpiredJwtException e) {
return ValidationResult.failure("Token has expired");
} catch (SignatureException e) {
return ValidationResult.failure("Invalid token signature");
} catch (MalformedJwtException e) {
return ValidationResult.failure("Malformed token");
} catch (JwtException e) {
return ValidationResult.failure("Invalid token: " + e.getMessage());
}
}Type-specific Validation
Access token and refresh token validation adds a type check on top of general validation:
public ValidationResult validateAccessToken(String token) {
ValidationResult result = validate(token);
if (!result.isValid()) {
return result;
}
String type = result.getClaims().get("type", String.class);
if (!"access".equals(type)) {
return ValidationResult.failure("Token is not an access token");
}
return result;
}
public ValidationResult validateRefreshToken(String token) {
ValidationResult result = validate(token);
if (!result.isValid()) {
return result;
}
String type = result.getClaims().get("type", String.class);
if (!"refresh".equals(type)) {
return ValidationResult.failure("Token is not a refresh token");
}
return result;
}This prevents token confusion attacks where a refresh token is used as an access token or vice versa.
Validation Result
The validation result is an immutable value object that encapsulates either a successful parse with claims or a failure with an error message:
public static class ValidationResult {
private final boolean valid;
private final Claims claims;
private final String error;
public static ValidationResult success(Claims claims) {
return new ValidationResult(true, claims, null);
}
public static ValidationResult failure(String error) {
return new ValidationResult(false, null, error);
}
public boolean isValid() { return valid; }
public Claims getClaims() { return claims; }
public String getError() { return error; }
public Optional<Claims> getClaimsOptional() {
return Optional.ofNullable(claims);
}
}The getClaimsOptional() method provides a safe way to chain claim extraction without null checks:
validator.validate(token)
.getClaimsOptional()
.map(claims -> claims.get("tenant_id", String.class))
.ifPresent(tenantId -> TenantContextHolder.setTenantId(tenantId));Error Handling
Each validation failure produces a specific error message that helps with debugging while avoiding information leakage:
| Exception | Error Message | HTTP Status | Common Cause |
|---|---|---|---|
ExpiredJwtException | "Token has expired" | 401 | Normal expiration; client should refresh |
SignatureException | "Invalid token signature" | 401 | Tampered token or wrong signing key |
MalformedJwtException | "Malformed token" | 401 | Corrupted or truncated token |
JwtException (other) | "Invalid token: ..." | 401 | Missing required claims, wrong algorithm |
| Type mismatch | "Token is not an access token" | 401 | Using refresh token as access token |
Security Considerations for Error Messages
The error messages returned to clients are intentionally generic. The detailed exception information (stack traces, specific claim values) is logged server-side but never exposed in HTTP responses:
{
"error": "Unauthorized",
"message": "Token validation failed",
"status": 401
}The detailed error (e.g., "Token has expired" vs "Invalid token signature") is logged at WARN level for security monitoring:
WARN c.m.c.s.a.JwtAuthFilter - Token validation failed: Token has expired
request_id=req-abc123 path=/api/v1/queries source_ip=10.0.1.50Expiration Check Utility
The validator provides a lightweight method to check if a token is expired without performing full validation. This is useful for client-side logic that needs to decide whether to refresh:
public boolean isExpired(String token) {
try {
parser.parseSignedClaims(token);
return false;
} catch (ExpiredJwtException e) {
return true;
} catch (JwtException e) {
return true; // Treat any other error as expired
}
}Note that this method returns true for any invalid token, not just expired ones. This is intentional: if a token is invalid for any reason, the client should treat it as expired and attempt a refresh.
Claim Extraction
The validator provides dedicated extraction methods for the most commonly needed claims:
User ID Extraction
public Optional<String> extractUserId(String token) {
ValidationResult result = validate(token);
if (result.isValid()) {
return Optional.ofNullable(result.getClaims().getSubject());
}
return Optional.empty();
}Tenant ID Extraction
public Optional<String> extractTenantId(String token) {
ValidationResult result = validate(token);
if (result.isValid()) {
return Optional.ofNullable(result.getClaims().get("tenant_id", String.class));
}
return Optional.empty();
}Role Extraction
@SuppressWarnings("unchecked")
public Set<String> extractRoles(String token) {
ValidationResult result = validate(token);
if (result.isValid()) {
Object roles = result.getClaims().get("roles");
if (roles instanceof Collection) {
return new HashSet<>((Collection<String>) roles);
}
}
return Set.of();
}The role extraction method returns an empty set (not null) when the token is invalid or has no roles. This allows safe iteration without null checks.
Python-side Validation
The AI service (Python) validates JWT tokens using the same shared secret and algorithm. The configuration is loaded from environment variables:
# From data-plane/ai-service/src/config/settings.py
jwt_secret_key: str = Field(
...,
description="JWT secret key - MUST be set via JWT_SECRET_KEY env var",
validation_alias=AliasChoices("MATIH_AI_JWT_SECRET_KEY", "JWT_SECRET_KEY"),
)
jwt_algorithm: str = "HS256"Python validation uses the PyJWT library:
import jwt
def validate_token(token: str, secret_key: str, algorithm: str = "HS256") -> dict:
"""Validate JWT token and return decoded claims."""
try:
payload = jwt.decode(
token,
secret_key,
algorithms=[algorithm],
issuer="matih-platform",
)
return payload
except jwt.ExpiredSignatureError:
raise AuthenticationError("Token has expired")
except jwt.InvalidSignatureError:
raise AuthenticationError("Invalid token signature")
except jwt.InvalidTokenError as e:
raise AuthenticationError(f"Invalid token: {str(e)}")Integration with Tenant Context
After successful validation, the extracted tenant ID is set on the TenantContextHolder for the current request thread:
// In the JWT authentication filter
ValidationResult result = validator.validateAccessToken(token);
if (result.isValid()) {
String tenantId = result.getClaims().get("tenant_id", String.class);
String userId = result.getClaims().getSubject();
TenantContextHolder.setTenant(tenantId, userId);
// ... set Spring Security authentication
}This ensures that all downstream code (repositories, services, audit logging) has access to the tenant context without explicitly passing it through method parameters. See Tenant Context for details.
Performance Considerations
Token validation is performed on every API request, so performance matters:
| Operation | Typical Latency |
|---|---|
| HMAC-SHA256 signature verification | < 0.1 ms |
| Full claim parsing and validation | < 0.5 ms |
| Revocation check (Redis lookup) | 1-5 ms |
The JwtParser instance is created once and reused across all validation calls. It is thread-safe and designed for high-concurrency use.
Related Pages
- JWT Architecture -- Overview of the JWT system
- Token Structure -- Header, payload, and signature details
- Token Lifecycle -- Issuance, refresh, revocation flows
- Tenant Context -- How tenant context propagates after validation