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

Token Lifecycle

This page documents the complete lifecycle of JWT tokens in the MATIH Platform, from initial issuance through refresh, revocation, and expiration. Understanding this lifecycle is essential for implementing correct authentication flows in clients and for debugging token-related issues.


Lifecycle Overview

Every token in the MATIH Platform passes through a defined set of states:

StateDescriptionDuration
IssuedToken is created and returned to the clientInstantaneous
ActiveToken is valid and can be used for authenticationUntil expiration or revocation
ExpiredToken has passed its exp claim timestampPermanent
RevokedToken has been explicitly invalidated before expirationPermanent

The transitions between states are one-directional. Once a token expires or is revoked, it cannot be reactivated.


Issuance Flow

User Authentication

The standard user authentication flow produces a token pair (access + refresh):

Step 1: User submits credentials

The client sends a POST request to the IAM service's login endpoint:

POST /api/v1/auth/login
Content-Type: application/json

{
  "email": "user@acme-corp.com",
  "password": "********",
  "tenant_slug": "acme-corp"
}

Step 2: IAM service validates credentials

The IAM service verifies the password against the stored bcrypt hash, checks account status, and loads role assignments for the specified tenant.

Step 3: MFA challenge (if enabled)

If the user has MFA enabled, the IAM service returns a challenge token instead of the full token pair:

{
  "mfa_required": true,
  "challenge_token": "mfa-challenge-uuid",
  "mfa_methods": ["totp", "email"]
}

The client must then submit the MFA code:

POST /api/v1/auth/mfa/verify
Content-Type: application/json

{
  "challenge_token": "mfa-challenge-uuid",
  "code": "123456",
  "method": "totp"
}

Step 4: Token pair generation

Upon successful authentication (and MFA if required), the IAM service generates a token pair:

// From JwtTokenProvider.java
public TokenPair generateTokenPair(String userId, String tenantId, Set<String> roles) {
    return new TokenPair(
        generateAccessToken(userId, tenantId, roles),
        generateRefreshToken(userId, tenantId)
    );
}

Step 5: Response to client

{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "tenant_id": "acme-corp",
  "roles": ["analyst", "operator"]
}

Step 6: Audit logging

Every successful and failed authentication attempt is logged:

// From AuditLogger.java
public void logLogin(String userId, String sourceIp) {
    log(AuditEvent.authentication()
        .action(AuditEvent.Action.LOGIN)
        .userId(userId)
        .sourceIp(sourceIp)
        .tenantId(LogContext.getTenantId())
        .correlationId(LogContext.getCorrelationId())
        .success()
        .build());
}
 
public void logLoginFailure(String attemptedUser, String sourceIp, String reason) {
    log(AuditEvent.authentication()
        .action(AuditEvent.Action.LOGIN)
        .detail("attemptedUser", attemptedUser)
        .sourceIp(sourceIp)
        .failure(reason)
        .build());
}

Token Refresh Flow

When an access token expires (or is about to expire), the client uses the refresh token to obtain a new access token without requiring the user to re-authenticate.

Step 1: Client detects expiration

The client checks the exp claim or the expires_in value from the original response. Best practice is to refresh proactively when the token has less than 60 seconds remaining.

Step 2: Refresh request

POST /api/v1/auth/refresh
Content-Type: application/json

{
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9..."
}

Step 3: IAM service validates refresh token

The IAM service performs the following checks:

// From JwtTokenValidator.java
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;
}
  1. Signature verification (is the token signed by our key?)
  2. Expiration check (has the refresh token expired?)
  3. Type validation (is this actually a refresh token, not an access token?)
  4. Revocation check (has this refresh token been revoked in Redis?)

Step 4: Fresh role lookup

The IAM service loads the user's current roles from the database. This ensures that any role changes made since the last login take effect immediately at the next refresh.

Step 5: New token pair generation

A completely new token pair is generated. The old refresh token is consumed (single-use refresh tokens).

Step 6: Response

{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...(new)",
  "refresh_token": "eyJhbGciOiJIUzI1NiJ9...(new)",
  "token_type": "Bearer",
  "expires_in": 900
}

Token Revocation

Tokens can be explicitly revoked before their natural expiration. The platform supports three revocation scopes:

Single Token Revocation

Revoke a specific token by its jti claim:

POST /api/v1/auth/revoke
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}

The token's jti is added to a Redis blacklist with a TTL matching the token's remaining lifetime. This ensures the blacklist entry is automatically cleaned up after the token would have expired naturally.

Session Revocation

Revoke all tokens associated with a user session:

POST /api/v1/auth/logout
Authorization: Bearer <access_token>

This revokes both the access token and the associated refresh token, effectively logging the user out.

User-wide Revocation

Revoke all tokens for a specific user (used by administrators):

POST /api/v1/admin/users/{userId}/revoke-tokens
Authorization: Bearer <admin_access_token>

This increments a per-user token version counter in Redis. All tokens issued before this version are considered revoked. This is more efficient than blacklisting individual tokens when a user has many active sessions.


Expiration Handling

Access Token Expiration

Access tokens expire after 15 minutes by default. The expiration time is configurable at JwtTokenProvider construction:

// Default: 15 minutes access, 7 days refresh
public JwtTokenProvider(String secret, String issuer) {
    this(secret, issuer, Duration.ofMinutes(15), Duration.ofDays(7));
}
 
// Custom durations
public JwtTokenProvider(String secret, String issuer,
                        Duration accessTokenValidity, Duration refreshTokenValidity) {
    // ...
}

When an expired access token is presented, the validator returns a clear error:

// From JwtTokenValidator.java
try {
    Jws<Claims> jws = parser.parseSignedClaims(token);
    Claims claims = jws.getPayload();
    return ValidationResult.success(claims);
} catch (ExpiredJwtException e) {
    return ValidationResult.failure("Token has expired");
}

Refresh Token Expiration

Refresh tokens expire after 7 days by default. When a refresh token expires, the user must re-authenticate with their credentials. This provides a hard limit on how long a user can remain authenticated without proving their identity.

Service Token Expiration

Service tokens have a very short lifetime of 5 minutes. Services generate a new token for each inter-service call or batch of calls. This minimizes the window of exposure if a service token is intercepted.

API Key Expiration

API key tokens have configurable expiration set at creation time. The maximum allowed expiration is governed by tenant-level policy (default: 365 days).


Token Lifetime Configuration

Token TypeDefault LifetimeConfigurableConfiguration Method
Access token15 minutesYesJwtTokenProvider constructor
Refresh token7 daysYesJwtTokenProvider constructor
Service token5 minutesNoHardcoded for security
API key tokenVariableYesSet at key creation time

Environment-based Configuration

In production deployments, token lifetimes are configured via Helm values:

# infrastructure/helm/control-plane/iam-service/values.yaml
env:
  JWT_ACCESS_TOKEN_VALIDITY_MINUTES: "15"
  JWT_REFRESH_TOKEN_VALIDITY_DAYS: "7"

Clock Skew Handling

In distributed systems, clock differences between servers can cause tokens to appear expired on one server while still valid on another. The JJWT library's parser can be configured with a clock skew tolerance:

this.parser = Jwts.parser()
        .verifyWith(signingKey)
        .requireIssuer(expectedIssuer)
        .build();

The platform relies on NTP synchronization across all Kubernetes nodes to keep clocks within acceptable bounds. Kubernetes nodes are expected to have clocks synchronized to within 1 second.


Token Storage Best Practices

Client-side Storage

Storage MethodSecurity LevelRecommended For
HttpOnly cookieHighWeb applications
Secure memoryHighMobile applications
localStorageMediumSingle-page apps (with XSS protection)
sessionStorageMediumTab-scoped sessions

The platform's frontend applications store access tokens in memory and refresh tokens in HttpOnly cookies. This prevents XSS attacks from extracting the refresh token.

Server-side Storage

Refresh tokens are tracked server-side in Redis for revocation support:

Key:    matih:auth:refresh:{jti}
Value:  {userId, tenantId, issuedAt}
TTL:    7 days (matches token expiration)

Related Pages