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

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.SIGNATURE

The three parts are:

PartContentEncoding
HeaderAlgorithm and token type metadataBase64URL
PayloadClaims (identity, tenant, roles, timestamps)Base64URL
SignatureHMAC-SHA256 of header + payloadBase64URL

Header

The JWT header declares the signing algorithm and token format. All MATIH tokens use the same header:

{
  "alg": "HS256"
}
FieldValueDescription
algHS256HMAC 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:

ClaimNameDescriptionExample
jtiJWT IDUnique identifier for the token"550e8400-e29b-41d4-a716-446655440000"
subSubjectUser ID or service name"user-123" or "ai-service"
issIssuerToken issuer (always matih-platform)"matih-platform"
iatIssued AtTimestamp when the token was created1707782400
expExpirationTimestamp when the token expires1707783300

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 ClaimTypeDescription
typeStringAlways "access" for access tokens
tenant_idStringThe tenant this token is scoped to
rolesArray of StringRole 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:

ClaimTypePurpose
emailStringUser email for display purposes
display_nameStringUser display name
mfa_verifiedBooleanWhether MFA was completed
session_idStringSession identifier for audit correlation
ip_addressStringSource 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 ClaimTypeDescription
typeStringAlways "refresh"
tenant_idStringTenant 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 ClaimTypeDescription
typeStringAlways "service"
scopesArray of StringScoped 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 ClaimTypeDescription
typeStringAlways "api_key"
tenant_idStringTenant this key is bound to
permissionsArray of StringExplicit permission list (not role references)

API key tokens differ from access tokens in two important ways:

  1. No sub claim. API keys are not associated with a specific user. The jti claim serves as the key identifier.
  2. 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 TypeTypical SizeNotes
Access token (2 roles)~350 bytesGrows with role count
Access token (5 roles, extra claims)~500 bytesAdditional claims add overhead
Refresh token~250 bytesMinimal claims
Service token (3 scopes)~300 bytesCompact
API key token (10 permissions)~450 bytesGrows 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