MATIH Platform is in active MVP development. Documentation reflects current implementation status.
3. Security & Multi-Tenancy
jwt-tokens
Token Validation

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:

StageCheckFailure Result
1. Header extractionAuthorization header present and formatted as Bearer <token>401 Unauthorized
2. Security filterInput validation on headers, query params, URI400 Bad Request
3. Signature verificationToken is signed by the platform's secret key401 Invalid signature
4. Expiration checkToken has not passed its exp claim401 Token expired
5. Issuer verificationiss claim matches expected issuer401 Invalid token
6. Type validationtype claim matches expected token type for the endpoint401 Wrong token type
7. Revocation checkToken jti is not in the revocation blacklist401 Token revoked
8. Tenant extractiontenant_id claim is present and valid403 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:

ExceptionError MessageHTTP StatusCommon Cause
ExpiredJwtException"Token has expired"401Normal expiration; client should refresh
SignatureException"Invalid token signature"401Tampered token or wrong signing key
MalformedJwtException"Malformed token"401Corrupted or truncated token
JwtException (other)"Invalid token: ..."401Missing required claims, wrong algorithm
Type mismatch"Token is not an access token"401Using 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.50

Expiration 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:

OperationTypical 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