JWT Architecture
JSON Web Tokens (JWT) are the backbone of authentication and authorization in the MATIH Platform. Every user request, service-to-service call, and API integration is authenticated via a signed JWT that carries identity, tenant context, and permission claims. This section provides a deep technical reference to the JWT architecture, its implementation, and the design decisions behind it.
Overview
The MATIH JWT system is implemented in two complementary components:
| Component | Language | Location | Responsibility |
|---|---|---|---|
| JwtTokenProvider | Java | commons/commons-java/.../security/authentication/JwtTokenProvider.java | Token generation (access, refresh, service, API key) |
| JwtTokenValidator | Java | commons/commons-java/.../security/authentication/JwtTokenValidator.java | Token parsing, signature verification, claim extraction |
| SecurityFilter | Java | commons/commons-java/.../security/SecurityFilter.java | Request-level header validation and attack detection |
| ApiKeyAuthFilter | Java | commons/commons-java/.../security/authentication/ApiKeyAuthFilter.java | API key authentication for programmatic access |
The platform uses the JJWT library (version 0.12.3) for all JWT operations. JJWT provides a fluent API for building and parsing JWTs with strong type safety and built-in protection against common token attacks.
Token Types
MATIH defines four distinct token types, each identified by a type claim in the JWT payload. This type-based distinction prevents token confusion attacks where a refresh token could be mistakenly accepted as an access token.
Access Tokens
Access tokens are short-lived credentials used to authenticate API requests. They carry the full user context including tenant ID and role assignments.
| Property | Value |
|---|---|
| Claim type | access |
| Default lifetime | 15 minutes |
| Contains | User ID, tenant ID, roles, custom claims |
| Used by | All API endpoints |
| Refreshable | Yes, via refresh token |
// From JwtTokenProvider.java
public String generateAccessToken(String userId, String tenantId,
Set<String> roles, Map<String, Object> additionalClaims) {
Instant now = Instant.now();
Instant expiry = now.plus(accessTokenValidity);
var builder = Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.claim("type", "access")
.claim("tenant_id", tenantId)
.claim("roles", roles);
additionalClaims.forEach(builder::claim);
return builder.signWith(signingKey).compact();
}Refresh Tokens
Refresh tokens are longer-lived tokens used exclusively to obtain new access tokens without requiring the user to re-authenticate. They carry minimal claims to reduce exposure.
| Property | Value |
|---|---|
| Claim type | refresh |
| Default lifetime | 7 days |
| Contains | User ID, tenant ID only |
| Used by | Token refresh endpoint only |
| Stored | Server-side in Redis for revocation |
// From JwtTokenProvider.java
public String generateRefreshToken(String userId, String tenantId) {
Instant now = Instant.now();
Instant expiry = now.plus(refreshTokenValidity);
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.claim("type", "refresh")
.claim("tenant_id", tenantId)
.signWith(signingKey)
.compact();
}Service Tokens
Service tokens are very short-lived tokens used for inter-service communication within the platform. They identify the calling service and carry scoped permissions rather than user roles.
| Property | Value |
|---|---|
| Claim type | service |
| Default lifetime | 5 minutes |
| Contains | Service name, scopes |
| Used by | Internal service-to-service calls |
| No tenant context | Service tokens are tenant-agnostic |
// From JwtTokenProvider.java
public String generateServiceToken(String serviceName, Set<String> scopes) {
Instant now = Instant.now();
Instant expiry = now.plus(Duration.ofMinutes(5));
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(serviceName)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.claim("type", "service")
.claim("scopes", scopes)
.signWith(signingKey)
.compact();
}API Key Tokens
API key tokens are long-lived tokens for programmatic access. They are bound to a specific tenant and carry explicit permission sets rather than role references.
| Property | Value |
|---|---|
| Claim type | api_key |
| Lifetime | Configurable (days to months) |
| Contains | Key ID, tenant ID, explicit permissions |
| Used by | External integrations, CI/CD pipelines |
| Revocable | Yes, via API key management |
// From JwtTokenProvider.java
public String generateApiKeyToken(String keyId, String tenantId,
Set<String> permissions, Duration validity) {
Instant now = Instant.now();
Instant expiry = now.plus(validity);
return Jwts.builder()
.id(keyId)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.claim("type", "api_key")
.claim("tenant_id", tenantId)
.claim("permissions", permissions)
.signWith(signingKey)
.compact();
}Token Pair Generation
When a user authenticates, the platform generates a token pair containing both an access token and a refresh token. This is the standard flow for interactive sessions.
// From JwtTokenProvider.java
public TokenPair generateTokenPair(String userId, String tenantId, Set<String> roles) {
return new TokenPair(
generateAccessToken(userId, tenantId, roles),
generateRefreshToken(userId, tenantId)
);
}
public record TokenPair(String accessToken, String refreshToken) {}The token pair pattern enables the platform to use short-lived access tokens (15 minutes) while providing a seamless user experience through automatic token refresh. The client stores both tokens and uses the refresh token to obtain a new access token before or after the current one expires.
Signing Configuration
All tokens are signed using HMAC-SHA256 (HS256). The signing key is derived from a secret string that is injected at runtime via environment variables. The key is never stored in code or configuration files committed to version control.
// From JwtTokenProvider.java
public JwtTokenProvider(String secret, String issuer,
Duration accessTokenValidity, Duration refreshTokenValidity) {
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.issuer = issuer;
this.accessTokenValidity = accessTokenValidity;
this.refreshTokenValidity = refreshTokenValidity;
}| Configuration | Source | Description |
|---|---|---|
JWT_SECRET_KEY | Environment variable / K8s Secret | Signing key (minimum 256 bits) |
jwt_algorithm | Settings | Algorithm (default: HS256) |
issuer | Constructor parameter | Token issuer claim (e.g., matih-platform) |
The Python AI service also validates JWT tokens using the same shared secret:
# 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 or MATIH_AI_JWT_SECRET_KEY env var",
validation_alias=AliasChoices("MATIH_AI_JWT_SECRET_KEY", "JWT_SECRET_KEY"),
)
jwt_algorithm: str = "HS256"Security Headers and Request Filtering
Before JWT validation occurs, the SecurityFilter applies input validation to all incoming requests. This filter runs early in the filter chain to catch malicious requests before they reach authentication logic.
// From SecurityFilter.java
private static final Set<String> VALIDATED_HEADERS = Set.of(
"X-Tenant-ID",
"X-User-ID",
"X-Request-ID",
"X-Correlation-ID"
);The filter also adds security response headers to every response:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevent MIME type sniffing |
X-Frame-Options | DENY | Prevent clickjacking |
X-XSS-Protection | 1; mode=block | Enable browser XSS filtering |
Cache-Control | no-store, no-cache, must-revalidate | Prevent token caching |
Design Decisions
Why HS256 Instead of RS256?
The platform uses symmetric signing (HS256) rather than asymmetric signing (RS256) for several reasons:
- Single trust domain. All services that validate tokens are part of the same platform and deployed in the same Kubernetes cluster. There is no need for public key distribution.
- Performance. HS256 is significantly faster than RS256 for both signing and verification.
- Simplicity. A single shared secret is easier to manage than a public/private key pair with certificate rotation.
- Kubernetes Secrets. The shared secret is distributed via Kubernetes Secrets, which provides adequate protection within the cluster boundary.
Why Four Token Types?
The four-type model prevents token confusion attacks and supports different access patterns:
- Access + Refresh separation prevents long-lived tokens from being used for API access. If an access token is compromised, it expires in 15 minutes.
- Service tokens are separate from user tokens to prevent privilege escalation. A compromised service token cannot be used to impersonate a user.
- API key tokens carry explicit permissions rather than role references, making them auditable and revocable independently of the role system.
Related Pages
- Token Structure -- Detailed breakdown of JWT header, payload, and signature
- Token Lifecycle -- Issuance, refresh, revocation, and expiration flows
- Token Validation -- Signature verification and claim validation logic
- Authentication -- Full authentication architecture including OAuth2 and MFA