Token Structure
Every JWT issued by the MATIH Platform follows the standard three-part structure: header, payload, and signature. This page provides a detailed breakdown of each part, the specific claims used across all four token types, and the encoding format.
JWT Format
A MATIH JWT is a Base64URL-encoded string with three segments separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzdWIiOiJ1c2VyLTEyMyIsImlzcyI6Im1hdGloLXBsYXRmb3JtIiwiaWF0IjoxNzA3NzgyNDAwLCJleHAiOjE3MDc3ODMzMDAsInR5cGUiOiJhY2Nlc3MiLCJ0ZW5hbnRfaWQiOiJhY21lLWNvcnAiLCJyb2xlcyI6WyJhbmFseXN0Iiwib3BlcmF0b3IiXX0.SIGNATUREThe three parts are:
| Part | Content | Encoding |
|---|---|---|
| Header | Algorithm and token type metadata | Base64URL |
| Payload | Claims (identity, tenant, roles, timestamps) | Base64URL |
| Signature | HMAC-SHA256 of header + payload | Base64URL |
Header
The JWT header declares the signing algorithm and token format. All MATIH tokens use the same header:
{
"alg": "HS256"
}| Field | Value | Description |
|---|---|---|
alg | HS256 | HMAC using SHA-256 hash algorithm |
The JJWT library automatically sets the alg field based on the signing key provided to the builder. The platform does not use the typ header field, as all tokens are processed through MATIH-specific validation logic rather than generic JWT middleware.
Payload: Standard Claims
MATIH uses both registered JWT claims (defined in RFC 7519) and custom claims. The registered claims provide standard token metadata:
| Claim | Name | Description | Example |
|---|---|---|---|
jti | JWT ID | Unique identifier for the token | "550e8400-e29b-41d4-a716-446655440000" |
sub | Subject | User ID or service name | "user-123" or "ai-service" |
iss | Issuer | Token issuer (always matih-platform) | "matih-platform" |
iat | Issued At | Timestamp when the token was created | 1707782400 |
exp | Expiration | Timestamp when the token expires | 1707783300 |
The jti claim is always a UUID v4 generated at token creation time, except for API key tokens where it matches the key ID. This enables token-level tracking and revocation.
// UUID generation for jti claim
.id(UUID.randomUUID().toString())Payload: Custom Claims
Beyond the standard claims, MATIH tokens carry custom claims that vary by token type.
Access Token Claims
{
"jti": "550e8400-e29b-41d4-a716-446655440000",
"sub": "user-123",
"iss": "matih-platform",
"iat": 1707782400,
"exp": 1707783300,
"type": "access",
"tenant_id": "acme-corp",
"roles": ["analyst", "operator"]
}| Custom Claim | Type | Description |
|---|---|---|
type | String | Always "access" for access tokens |
tenant_id | String | The tenant this token is scoped to |
roles | Array of String | Role names assigned to the user within this tenant |
Access tokens may also carry additional custom claims injected at generation time:
// Additional claims are passed as a Map
public String generateAccessToken(String userId, String tenantId,
Set<String> roles, Map<String, Object> additionalClaims) {
// ...
additionalClaims.forEach(builder::claim);
return builder.signWith(signingKey).compact();
}Common additional claims include:
| Claim | Type | Purpose |
|---|---|---|
email | String | User email for display purposes |
display_name | String | User display name |
mfa_verified | Boolean | Whether MFA was completed |
session_id | String | Session identifier for audit correlation |
ip_address | String | Source IP at authentication time |
Refresh Token Claims
{
"jti": "661f9500-f30c-52e5-b827-557766550000",
"sub": "user-123",
"iss": "matih-platform",
"iat": 1707782400,
"exp": 1708387200,
"type": "refresh",
"tenant_id": "acme-corp"
}| Custom Claim | Type | Description |
|---|---|---|
type | String | Always "refresh" |
tenant_id | String | Tenant scope for the refresh operation |
Refresh tokens intentionally omit role claims. When a refresh token is used to obtain a new access token, the IAM service looks up the user's current roles from the database. This ensures that role changes take effect at the next token refresh without requiring token revocation.
Service Token Claims
{
"jti": "772a0600-a41d-63f6-c938-668877660000",
"sub": "ai-service",
"iss": "matih-platform",
"iat": 1707782400,
"exp": 1707782700,
"type": "service",
"scopes": ["query:execute", "schema:read", "catalog:read"]
}| Custom Claim | Type | Description |
|---|---|---|
type | String | Always "service" |
scopes | Array of String | Scoped permissions for the service call |
Service tokens do not carry a tenant_id claim. The tenant context for service-to-service calls is propagated via the X-Tenant-ID HTTP header, which is set by the originating service based on the user's access token.
API Key Token Claims
{
"jti": "ak_883b1700-b52e-74g7-d049-779988770000",
"iss": "matih-platform",
"iat": 1707782400,
"exp": 1715558400,
"type": "api_key",
"tenant_id": "acme-corp",
"permissions": ["data:read", "queries:execute", "reports:read"]
}| Custom Claim | Type | Description |
|---|---|---|
type | String | Always "api_key" |
tenant_id | String | Tenant this key is bound to |
permissions | Array of String | Explicit permission list (not role references) |
API key tokens differ from access tokens in two important ways:
- No
subclaim. API keys are not associated with a specific user. Thejticlaim serves as the key identifier. - Explicit permissions. Instead of referencing roles, the token contains the exact permissions granted to the key. This makes the key's capabilities self-documenting and independent of role changes.
Signature
The signature is computed by HMAC-SHA256 over the Base64URL-encoded header and payload:
HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
signingKey
)The signing key is derived from the JWT_SECRET_KEY environment variable using JJWT's Keys.hmacShaKeyFor() method:
// From JwtTokenProvider.java
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));The key must be at least 256 bits (32 bytes) to satisfy the HMAC-SHA256 requirement. If a shorter key is provided, JJWT will throw a WeakKeyException at initialization time.
Token Size Considerations
JWT size directly affects request overhead since the token is included in every HTTP request as a Bearer token in the Authorization header. Here are typical sizes for each token type:
| Token Type | Typical Size | Notes |
|---|---|---|
| Access token (2 roles) | ~350 bytes | Grows with role count |
| Access token (5 roles, extra claims) | ~500 bytes | Additional claims add overhead |
| Refresh token | ~250 bytes | Minimal claims |
| Service token (3 scopes) | ~300 bytes | Compact |
| API key token (10 permissions) | ~450 bytes | Grows with permission count |
For users with many roles or API keys with many permissions, consider using role hierarchies to reduce the number of role claims, or reference a permission group rather than listing individual permissions.
Token Encoding and Transport
Tokens are transported in HTTP requests using the standard Bearer token scheme:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJqdGki...For WebSocket connections, the token is passed as a query parameter during the handshake:
wss://api.matih.example.com/ws?token=eyJhbGciOiJIUzI1NiJ9.eyJqdGki...For SSE (Server-Sent Events) streams, the token is passed in the initial HTTP request's Authorization header, same as regular API calls.
Claim Extraction Utilities
The JwtTokenValidator provides type-safe extraction methods for common claims:
// From JwtTokenValidator.java
// Extract user ID
public Optional<String> extractUserId(String token) {
ValidationResult result = validate(token);
if (result.isValid()) {
return Optional.ofNullable(result.getClaims().getSubject());
}
return Optional.empty();
}
// Extract tenant ID
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();
}
// Extract roles
@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();
}These methods return Optional types to force callers to handle the case where the claim is missing or the token is invalid, preventing null pointer exceptions in downstream code.
Related Pages
- JWT Architecture -- Overview of the JWT system
- Token Lifecycle -- Issuance, refresh, revocation flows
- Token Validation -- Signature verification and claim validation