Skip to main content

JWT Token Management System Design

Comprehensive low-level design for a secure JWT token management system with refresh token handling, blacklisting, and distributed session management.

A robust JWT token management system requires careful consideration of token lifecycle, security mechanisms, and scalability constraints for enterprise-grade applications.

1. Problem Statement & Requirements

Clarifying questions

  1. What scale expectations are there for the system? Are we talking about millions of concurrent users, or is this a smaller-scale internal tool?
  2. What consistency requirements exist for token validation and revocation? Should all token validations be strongly consistent, or can eventual consistency be acceptable?
  3. What are the key entities in the system and their relationships? Is it just JWTs, or do we need to model users, sessions, roles, permissions?
  4. What's the expected read/write ratio for token operations? Is validation far more frequent than creation or revocation?
  5. What are the latency sensitivity requirements? Are JWT validations expected to complete in under 10ms, or is sub-100ms acceptable?
  6. What authentication model does the system need to support? OAuth2, SAML, or just JWTs?
  7. Are there any external systems that will integrate with this JWT system? If so, what protocols or formats are expected?

Functional requirements

  • Generate JWT tokens for authenticated users with configurable claims and expiration
  • Validate JWT tokens at the time of use (on each request)
  • Revoke JWT tokens before expiration (blacklist or token store approach)
  • Refresh expired JWT tokens using refresh tokens
  • Manage token lifecycle (creation, validation, revocation, expiration)
  • Support multiple audiences and issuers for JWTs
  • Store token metadata (user ID, session ID, issued at, expires at)
  • Support token introspection endpoint for external validation
  • Handle token rotation (refresh tokens with new access tokens)
  • Maintain audit trail of token creation, validation, and revocation

Non-functional requirements

  • Latency targets:
    • p50: <5ms for token validation
    • p95: <20ms for token validation
    • p99: <50ms for token validation
  • Throughput: Support 10,000+ concurrent JWT validations per second
  • Availability: 99.9% uptime for token validation service (99.99% for refresh/revoke endpoints)
  • Scalability: Support horizontal scaling to 100+ instances
  • Security: All tokens must be cryptographically secure and properly signed/validated

Constraints and assumptions

  • JWTs are stateless and must be validated without database lookups for performance
  • Token validation should not require external network calls for basic validation (signature, expiration)
  • The system will support multiple audiences and issuers
  • Token revocation must be handled efficiently without impacting performance of normal validation flow
  • Assumption: The system will not store the actual JWT payload in plaintext (only metadata)
  • Assumption: All tokens are generated by this system and not from external sources

Out of scope

  • Token encryption (only signing is supported)
  • Support for JWTs from external providers (e.g., Auth0, Okta)
  • Token refresh token rotation (refresh tokens are not rotated)
  • Multi-tenancy support (single tenant only)
  • Token sharing between services (JWTs are service-specific)
  • Token expiration extension or renewal
  • Token storage in secure hardware modules (HSMs)

2. API Design

API Style Selection

gRPC with Protobuf is selected for the JWT token management system due to:

  • Strong typing and versioning support for evolving token schemas
  • Efficient binary serialization reducing network overhead
  • Built-in support for streaming and bidirectional communication
  • Native support for authentication mechanisms (OAuth2, JWT)
  • Better tooling ecosystem (protoc, grpcurl) for development and debugging
  • Clear contract definition that enables automated client generation

Endpoint Definitions

MethodPathDescriptionAuth
CreateToken/v1/token/createGenerate new JWT token with claimsJWT/OAuth2
RefreshToken/v1/token/refreshRefresh expired JWT tokenJWT/OAuth2
RevokeToken/v1/token/revokeInvalidate JWT token immediatelyJWT/OAuth2
ValidateToken/v1/token/validateValidate token and return claimsJWT/OAuth2
GetTokenInfo/v1/token/infoRetrieve token metadataJWT/OAuth2
ListTokens/v1/token/listList user's active tokensJWT/OAuth2

Request and Response Schemas

CreateToken Request

message CreateTokenRequest {
  string user_id = 1;
  map<string, string> claims = 2;
  int32 expiry_hours = 3;
  string audience = 4;
  string issuer = 5;
}

CreateToken Response

message CreateTokenResponse {
  string token = 1;
  int64 expiry_timestamp = 2;
  string token_id = 3;
}

RefreshToken Request

message RefreshTokenRequest {
  string refresh_token = 1;
}

RefreshToken Response

message RefreshTokenResponse {
  string token = 1;
  int64 expiry_timestamp = 2;
}

RevokeToken Request

message RevokeTokenRequest {
  string token_id = 1;
}

RevokeToken Response

message RevokeTokenResponse {
  bool success = 1;
}

ValidateToken Request

message ValidateTokenRequest {
  string token = 1;
}

ValidateToken Response

message ValidateTokenResponse {
  bool is_valid = 1;
  string user_id = 2;
  map<string, string> claims = 3;
  int64 expiry_timestamp = 4;
}

HTTP Status Codes and Error Response Format

Status CodeDescription
200Success
400Bad Request - Invalid input
401Unauthorized - Invalid or missing auth
403Forbidden - Insufficient permissions
429Rate Limited
500Internal Server Error

Standardized error response:

{
  "error": {
    "code": "INVALID_INPUT",
    "message": "Token ID is required",
    "details": {
      "field": "token_id"
    }
  }
}

API Versioning Strategy

Path-based versioning: /v1/token/... to support future API evolution without breaking existing clients. Version is included in the path and validated at the service boundary.

Pagination Design

Cursor-based pagination is used for token listing operations to:

  • Handle consistent ordering in the presence of concurrent writes
  • Provide stable pagination across system restarts
  • Avoid issues with data changes during pagination

Authentication and Authorization

OAuth2 Bearer Token + JWT is used for authentication:

  • All endpoints require valid JWT token in Authorization header
  • Token must be signed by trusted issuer (e.g., Auth0, Keycloak)
  • User ID from token claims is used for authorization checks
  • RBAC roles enforced at service layer (admin, user, service)

Rate Limiting

Token-based rate limiting per user with:

  • 100 requests/minute for token creation
  • 500 requests/minute for validation and refresh operations
  • Implementation using Redis with sliding window algorithm
  • Rate limit headers in responses: X-RateLimit-Limit, X-RateLimit-Remaining

Idempotency Design

Idempotency key strategy:

  • Clients provide X-Idempotency-Key header for mutation operations
  • Server stores operation state with key as primary identifier
  • If same key is used, return cached result instead of reprocessing
  • Idempotency keys expire after 1 hour

Example Request Flow (CreateToken)

POST /v1/token/create HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Idempotency-Key: user_123_token_create_abc123

{
  "user_id": "user_123",
  "claims": {
    "role": "admin",
    "scope": "read write"
  },
  "expiry_hours": 24,
  "audience": "api.example.com",
  "issuer": "auth.example.com"
}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiry_timestamp": 1704067200,
  "token_id": "tok_abc123"
}

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    USER {
        string id PK
        string email
        string hashed_password
        boolean is_active
        timestamp created_at
        timestamp updated_at
        timestamp last_login_at
    }

    JWT_TOKEN {
        string id PK
        string user_id FK
        string token_hash
        string jti
        timestamp expires_at
        timestamp created_at
        timestamp invalidated_at
        boolean is_active
    }

    USER_DEVICE {
        string id PK
        string user_id FK
        string device_fingerprint
        string device_info
        timestamp created_at
        timestamp last_used_at
    }

    JWT_TOKEN_DEVICE {
        string token_id FK
        string device_id FK
        timestamp created_at
    }

    USER ||--o{ JWT_TOKEN : "has"
    USER ||--o{ USER_DEVICE : "has"
    JWT_TOKEN ||--o{ JWT_TOKEN_DEVICE : "associated_with"
    USER_DEVICE ||--o{ JWT_TOKEN_DEVICE : "associated_with"

Table Schema Definitions

users table

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    hashed_password TEXT NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMP WITH TIME ZONE,
    
    INDEX idx_users_email (email),
    INDEX idx_users_active (is_active)
);

jwt_tokens table

CREATE TABLE jwt_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash TEXT NOT NULL,
    jti VARCHAR(255) UNIQUE NOT NULL,
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    invalidated_at TIMESTAMP WITH TIME ZONE,
    is_active BOOLEAN DEFAULT TRUE,
    
    INDEX idx_jwt_tokens_user_id (user_id),
    INDEX idx_jwt_tokens_jti (jti),
    INDEX idx_jwt_tokens_expires_at (expires_at),
    INDEX idx_jwt_tokens_active (is_active),
    INDEX idx_jwt_tokens_user_active (user_id, is_active)
);

user_devices table

CREATE TABLE user_devices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    device_fingerprint VARCHAR(255) NOT NULL,
    device_info TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_user_devices_user_id (user_id),
    INDEX idx_user_devices_fingerprint (device_fingerprint),
    UNIQUE idx_user_devices_unique_fingerprint (user_id, device_fingerprint)
);

jwt_token_devices table

CREATE TABLE jwt_token_devices (
    token_id UUID NOT NULL REFERENCES jwt_tokens(id) ON DELETE CASCADE,
    device_id UUID NOT NULL REFERENCES user_devices(id) ON DELETE CASCADE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    
    PRIMARY KEY (token_id, device_id),
    INDEX idx_jwt_token_devices_token_id (token_id),
    INDEX idx_jwt_token_devices_device_id (device_id)
);

Primary Key Strategy

UUIDs for all tables - Chosen over auto-increment for:

  • Global uniqueness across distributed systems
  • Prevention of enumeration attacks (predictable IDs)
  • Better sharding capabilities with UUID v1 timestamps
  • No need for coordination between services

Index Design

Index NameFieldsTypePurpose
idx_users_emailemailStandardFast email lookups
idx_users_activeis_activeStandardFilter active users
idx_jwt_tokens_user_iduser_idStandardFast token lookups by user
idx_jwt_tokens_jtijtiUniqueFast JTI-based token validation
idx_jwt_tokens_expires_atexpires_atStandardEfficient token expiration cleanup
idx_jwt_tokens_activeis_activeStandardFast active token filtering
idx_jwt_tokens_user_activeuser_id, is_activeCompositeFast lookup of active tokens per user
idx_user_devices_user_iduser_idStandardFast device lookups by user
idx_user_devices_fingerprintdevice_fingerprintStandardFast device fingerprint lookups
idx_jwt_token_devices_token_idtoken_idStandardFast device associations by token
idx_jwt_token_devices_device_iddevice_idStandardFast token associations by device

Data Partitioning / Sharding Strategy

Shard by user_id (hash-based) - Using consistent hashing with 64 partitions:

  • User ID hash modulo 64 determines shard
  • Allows for horizontal scaling across multiple database instances
  • Token and device data co-located per user for efficient joins

Soft Delete vs. Hard Delete Decision

Soft delete with invalidated_at field - Chosen for:

  • Audit trail preservation (compliance, security investigations)
  • Token revocation tracking without data loss
  • Historical token usage analysis (device tracking, session history)
  • Ability to restore tokens if needed (e.g., in case of false positives)

Audit Trail Design

Audit log table for token operations:

CREATE TABLE audit_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    operation_type VARCHAR(50) NOT NULL,
    user_id UUID REFERENCES users(id),
    token_id UUID REFERENCES jwt_tokens(id),
    device_id UUID REFERENCES user_devices(id),
    actor_user_id UUID REFERENCES users(id),
    ip_address VARCHAR(45),
    user_agent TEXT,
    timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_audit_logs_user_id (user_id),
    INDEX idx_audit_logs_timestamp (timestamp)
);

Data Archival Strategy and Retention Policy

Archival strategy:

  • Tokens older than 30 days with invalidated_at set are archived to cold storage (S3/BigQuery)
  • Audit logs older than 2 years are moved to data warehouse for analytics
  • Active tokens (current session) remain in primary database for fast access

Retention policy:

  • JWT tokens: 30 days (after invalidation)
  • Audit logs: 2 years
  • User devices: 1 year (inactive devices purged)
// Example token storage class with audit capability
public class JwtToken {
    private String id;
    private String userId;
    private String tokenHash;
    private String jti;
    private Instant expiresAt;
    private Instant createdAt;
    private Instant invalidatedAt;
    private boolean isActive;
    
    // Constructor, getters, setters...
    
    public boolean isExpired() {
        return Instant.now().isAfter(expiresAt);
    }
    
    public boolean isValid() {
        return isActive && !isExpired();
    }
}

4. Class Design & Service Layer

classDiagram
    class JWTTokenService {
        +generateToken(User user)
        +validateToken(String token)
        +revokeToken(String tokenId)
        +isTokenRevoked(String tokenId)
        +refreshToken(String oldToken)
    }
    
    class TokenRepository {
        +save(Token token)
        +findById(String tokenId)
        +findByUserId(String userId)
        +deleteById(String tokenId)
        +findAllByUserIdAndStatus(String userId, TokenStatus status)
    }
    
    class Token {
        -id: String
        -userId: String
        -token: String
        -status: TokenStatus
        -issuedAt: Instant
        -expiresAt: Instant
        -refreshToken: String
        -refreshExpiresAt: Instant
        +isExpired()
        +isRevoked()
    }
    
    class TokenStatus {
        -REVOKED
        -ACTIVE
        -EXPIRED
    }
    
    class TokenGenerator {
        +generateTokenPayload(User user)
        +signToken(String payload, String secret)
        +generateRefreshToken()
    }
    
    class TokenValidator {
        +validateSignature(String token, String secret)
        +extractClaims(String token)
        +validateExpiry(Instant expiryTime)
        +validateStatus(Token token)
    }
    
    class TokenManager {
        -tokenRepository: TokenRepository
        -tokenGenerator: TokenGenerator
        -tokenValidator: TokenValidator
        -jwtConfig: JWTConfig
        -cache: Cache<String, Token>
    }
    
    class JWTConfig {
        -issuer: String
        -audience: String
        -secretKey: String
        -accessTokenExpiry: Duration
        -refreshTokenExpiry: Duration
        -maxRefreshCount: int
    }
    
    JWTTokenService --> TokenManager : uses
    TokenManager --> TokenRepository : persists
    TokenManager --> TokenGenerator : generates
    TokenManager --> TokenValidator : validates
    TokenManager --> JWTConfig : configures
    Token --> TokenStatus : has status
stateDiagram-v2
    [*] --> ACTIVE
    ACTIVE --> REVOKED : revokeToken()
    ACTIVE --> EXPIRED : tokenExpired()
    REVOKED --> [*]
    EXPIRED --> [*]

Service Class Structure and Responsibilities

The JWT Token Management system is designed with a clean service-oriented architecture:

  1. JWTTokenService: Main entry point for all token operations (generate, validate, revoke, refresh)
  2. TokenManager: Core business logic coordinator that orchestrates between components
  3. TokenRepository: Data persistence layer for token records
  4. TokenGenerator: Token creation and signing logic
  5. TokenValidator: Token validation and claims extraction
  6. JWTConfig: Configuration container for security parameters

Interface / Abstract Class Design

public interface TokenRepository {
    void save(Token token);
    Optional<Token> findById(String tokenId);
    List<Token> findByUserId(String userId);
    void deleteById(String tokenId);
    List<Token> findAllByUserIdAndStatus(String userId, TokenStatus status);
}

public interface TokenGenerator {
    String generateTokenPayload(User user);
    String signToken(String payload, String secret);
    String generateRefreshToken();
}

public interface TokenValidator {
    boolean validateSignature(String token, String secret);
    Map<String, Object> extractClaims(String token);
    boolean validateExpiry(Instant expiryTime);
    boolean validateStatus(Token token);
}

public enum TokenStatus {
    ACTIVE, REVOKED, EXPIRED
}

Business Logic Encapsulation Patterns

The system implements the Strategy Pattern for token generation and validation to support extensibility:

public class TokenGenerationStrategy {
    public static final String HS256 = "HS256";
    public static final String RS256 = "RS256";
    
    public interface SignatureStrategy {
        String sign(String payload, String secret);
        boolean verify(String token, String secret);
    }
    
    public static class HS256Strategy implements SignatureStrategy {
        @Override
        public String sign(String payload, String secret) {
            // Implementation using HS256
            return Jwts.builder()
                .setPayload(payload)
                .signWith(SignatureAlgorithm.HS256, secret)
                .compact();
        }
        
        @Override
        public boolean verify(String token, String secret) {
            try {
                Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
                return true;
            } catch (JwtException e) {
                return false;
            }
        }
    }
}

Critical-Path Method Implementation

@Transactional
public String generateToken(User user) {
    // 1. Validate user and check for existing active tokens
    List<Token> existingTokens = tokenRepository.findAllByUserIdAndStatus(
        user.getId(), TokenStatus.ACTIVE);
    
    // 2. Generate new token and refresh token
    String payload = tokenGenerator.generateTokenPayload(user);
    String accessToken = tokenGenerator.signToken(payload, jwtConfig.getSecretKey());
    
    String refreshToken = tokenGenerator.generateRefreshToken();
    String refreshSignature = tokenGenerator.signToken(
        "refresh:" + user.getId(), jwtConfig.getSecretKey());
    
    // 3. Create token object with metadata
    Token token = new Token();
    token.setId(UUID.randomUUID().toString());
    token.setUserId(user.getId());
    token.setToken(accessToken);
    token.setStatus(TokenStatus.ACTIVE);
    token.setIssuedAt(Instant.now());
    token.setExpiresAt(Instant.now().plus(jwtConfig.getAccessTokenExpiry()));
    
    // 4. Store refresh token in database (optional - can be stored in secure cache)
    token.setRefreshToken(refreshSignature);
    token.setRefreshExpiresAt(Instant.now().plus(jwtConfig.getRefreshTokenExpiry()));
    
    // 5. Persist token record
    tokenRepository.save(token);
    
    // 6. Publish token creation event for audit and analytics
    eventPublisher.publish(new TokenCreatedEvent(
        token.getId(), user.getId(), token.getIssuedAt()));
    
    return accessToken;
}

Transaction Boundary Design

Each token operation is wrapped in a database transaction:

  • Token generation: Single transaction for creating the token record and publishing events
  • Token validation: Read-only operation (no transaction needed)
  • Token revocation: Single transaction for updating status and publishing events
  • Token refresh: Transactional operation that invalidates old token and creates new one

Service Interface Contracts

public interface JWTTokenService {
    String generateToken(User user);
    boolean validateToken(String token);
    void revokeToken(String tokenId);
    boolean isTokenRevoked(String tokenId);
    String refreshToken(String oldToken);
}

Saga / Orchestration Pattern

For distributed operations like token refresh with external service calls, the system uses a simplified orchestration pattern:

public class TokenRefreshSaga {
    private final TokenRepository tokenRepository;
    private final EventPublisher eventPublisher;
    
    public String refreshWithExternalValidation(String oldToken) {
        // 1. Validate and revoke old token
        Token old = validateAndRevokeToken(oldToken);
        
        // 2. Call external validation service (e.g., user service)
        User user = validateUserWithExternalService(old.getUserId());
        
        // 3. Generate new token
        String newToken = generateNewToken(user);
        
        // 4. Publish success event
        eventPublisher.publish(new TokenRefreshedEvent(
            old.getId(), newToken, user.getId()));
            
        return newToken;
    }
}

Domain Object / Value Object Design

public class Token {
    private String id;
    private String userId;
    private String token;
    private TokenStatus status;
    private Instant issuedAt;
    private Instant expiresAt;
    private String refreshToken;
    private Instant refreshExpiresAt;
    
    public boolean isExpired() {
        return expiresAt.isBefore(Instant.now());
    }
    
    public boolean isRevoked() {
        return status == TokenStatus.REVOKED;
    }
    
    public boolean isRefreshExpired() {
        return refreshExpiresAt.isBefore(Instant.now());
    }
}

public class JWTConfig {
    private String issuer;
    private String audience;
    private String secretKey;
    private Duration accessTokenExpiry;
    private Duration refreshTokenExpiry;
    private int maxRefreshCount;
    
    // Getters and setters
}

5. Concurrency & Thread Safety

Concurrency Model

The JWT token management system will operate in a multi-threaded environment with the following concurrency model:

  • Thread-per-request for synchronous operations (REST API handlers)
  • Async/Await pattern for asynchronous flows (event processing, background tasks)
  • Thread pools for I/O-bound operations (database queries, external API calls)
  • Reactive programming model for high-concurrency scenarios (e.g., token refresh operations)

Race Condition Prevention Strategy

Race conditions are mitigated through:

  1. Atomic operations for token counter increments and session state updates
  2. Database-level constraints (unique indexes, foreign key constraints)
  3. Optimistic locking with version fields for concurrent session updates
  4. Synchronized access to shared in-memory caches during token refresh operations
  5. Lock-free data structures for high-throughput token validation (e.g., concurrent hash maps)

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is preferred for the following reasons:

  • High read-to-write ratio: Token validation (read) far exceeds token creation/deletion (write)
  • Low contention: Concurrent token refresh operations are rare and typically don't conflict
  • Better performance: Avoids blocking threads during token validation operations
  • Scalability: Allows horizontal scaling without distributed lock coordination

Implementation pattern:

// Example optimistic locking in session update
public boolean updateSessionWithOptimisticLock(Session session, int expectedVersion) {
    String sql = "UPDATE sessions SET last_accessed = ?, version = version + 1 WHERE session_id = ? AND version = ?";
    
    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setTimestamp(1, new Timestamp(System.currentTimeMillis()));
        stmt.setString(2, session.getId());
        stmt.setInt(3, expectedVersion);
        
        int rowsAffected = stmt.executeUpdate();
        return rowsAffected > 0; // Success if one row was updated
    } catch (SQLException e) {
        throw new DataAccessException("Failed to update session", e);
    }
}

Deadlock Prevention Strategy

Deadlock prevention mechanisms:

  1. Consistent lock ordering: All database operations follow a consistent order (e.g., always acquire session locks before token locks)
  2. Timeout configuration: Set 5-second timeout for database transactions to prevent indefinite blocking
  3. Retry logic with exponential backoff: Retry failed operations up to 3 times with 100ms, 200ms, 400ms delays
  4. Connection pool monitoring: Track and log long-running transactions to detect potential deadlocks

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency is implemented through:

  1. Idempotency keys in request headers (X-Idempotency-Key)
  2. Cache-based idempotency store for recent operations (Redis or in-memory cache)
  3. Operation result caching with TTL of 5 minutes for token refresh operations
  4. Database uniqueness constraints on session creation and refresh operations
// Idempotency check implementation
public class IdempotencyService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public IdempotencyResult checkIdempotency(String idempotencyKey, String operationType) {
        String cacheKey = "idempotency:" + operationType + ":" + idempotencyKey;
        String result = redisTemplate.opsForValue().get(cacheKey);
        
        if (result != null) {
            return new IdempotencyResult(true, result); // Already processed
        }
        
        // Store operation in progress for 30 seconds to prevent duplicate processing
        redisTemplate.opsForValue().set(cacheKey, "processing", 30, TimeUnit.SECONDS);
        return new IdempotencyResult(false, null);
    }
    
    public void storeIdempotencyResult(String idempotencyKey, String operationType, String result) {
        String cacheKey = "idempotency:" + operationType + ":" + idempotencyKey;
        redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
    }
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant U as User
    participant AuthS as Auth Service
    participant DB as Database
    participant Cache as Redis Cache

    U->>AuthS: Request login with credentials
    AuthS->>DB: Validate user credentials
    DB-->>AuthS: Return user details
    AuthS->>Cache: Check if token exists in cache
    Cache-->>AuthS: Token not found
    AuthS->>DB: Create new JWT token record
    DB-->>AuthS: Token created with ID
    AuthS->>Cache: Store token in cache with TTL
    Cache-->>AuthS: Token stored
    AuthS-->>U: Return JWT token + refresh token
sequenceDiagram
    participant U as User
    participant AuthS as Auth Service
    participant DB as Database
    participant Cache as Redis Cache

    U->>AuthS: Request token refresh with refresh token
    AuthS->>Cache: Check if refresh token exists in cache
    Cache-->>AuthS: Refresh token found
    AuthS->>DB: Validate refresh token and user session
    DB-->>AuthS: Session valid, return user ID
    AuthS->>DB: Create new access token
    DB-->>AuthS: New token created with ID
    AuthS->>Cache: Update cache with new token
    Cache-->>AuthS: Token updated
    AuthS-->>U: Return new access token
sequenceDiagram
    participant U as User
    participant AuthS as Auth Service
    participant DB as Database
    participant Cache as Redis Cache
    participant EventQ as Message Queue

    U->>AuthS: Request logout (invalidate session)
    AuthS->>Cache: Remove access token from cache
    Cache-->>AuthS: Token invalidated
    AuthS->>DB: Mark token as revoked in DB
    DB-->>AuthS: Token marked as revoked
    AuthS->>EventQ: Publish session invalidation event
    EventQ-->>AuthS: Event published successfully
    AuthS-->>U: Return success response

7. Caching Design

Cache Strategy Selection

For JWT token management, we'll use a cache-aside pattern with write-through behavior for token validation. This approach provides the best balance between performance and consistency for a system where token validity must be immediately accurate.

Cache Key Design

Cache keys will follow a structured namespace pattern:

jwt:<token_type>:<user_id>:<jti>
jwt:refresh:<user_id>:<jti>
jwt:claims:<user_id>:<jti>

Where:

  • token_type = "access" | "refresh"
  • user_id = UUID of the user
  • jti = JWT ID (unique identifier)

TTL Configuration

Data TypeTTLReason
Access Tokens15 minutesStandard JWT expiration
Refresh Tokens7 daysExtended validity for refresh operations
Token Claims15 minutesSame as access token
Blacklisted Tokens30 daysAllow for grace period of token revocation

Cache Eviction Policy

We'll use LRU (Least Recently Used) eviction policy with:

  • Maximum cache size: 100,000 entries
  • Cache warm-up on startup with pre-loaded popular tokens

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic early expiry: Set cache TTL to 20% shorter than actual expiration
  2. Distributed locks: Use Redis distributed locks for cache population
  3. Cache warming: Pre-populate cache with frequently accessed tokens

Distributed Cache Architecture

Using Redis Cluster with:

  • 3 master nodes, 3 replica nodes
  • Automatic failover configuration
  • Cross-zone replication for high availability
  • Memory optimization with Redis 7.x with maxmemory policies

Cache Invalidation Strategy

Event-driven invalidation using:

  1. Token revocation events trigger immediate cache removal
  2. User session expiration triggers batch invalidation
  3. Periodic cleanup jobs for expired tokens (every 1 hour)
// Cache invalidation service example
@Component
public class TokenCacheInvalidator {
    
    private final RedisTemplate<String, Object> redisTemplate;
    
    public void invalidateToken(String userId, String jti, TokenType tokenType) {
        String cacheKey = buildCacheKey(userId, jti, tokenType);
        redisTemplate.delete(cacheKey);
        
        // Also invalidate related keys
        String claimsKey = buildClaimsCacheKey(userId, jti);
        redisTemplate.delete(claimsKey);
        
        // For refresh tokens, also invalidate refresh token cache
        if (tokenType == TokenType.REFRESH) {
            String refreshKey = buildRefreshCacheKey(userId, jti);
            redisTemplate.delete(refreshKey);
        }
    }
    
    private String buildCacheKey(String userId, String jti, TokenType tokenType) {
        return String.format("jwt:%s:%s:%s", tokenType.name().toLowerCase(), userId, jti);
    }
    
    private String buildClaimsCacheKey(String userId, String jti) {
        return String.format("jwt:claims:%s:%s", userId, jti);
    }
    
    private String buildRefreshCacheKey(String userId, String jti) {
        return String.format("jwt:refresh:%s:%s", userId, jti);
    }
}

Cache Read-Through Pattern Implementation

// Token validation with cache read-through
@Service
public class JwtTokenValidator {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final TokenRepository tokenRepository;
    
    public JwtValidationResult validateToken(String token, String userId) {
        // Try to get from cache first
        String cacheKey = buildCacheKey(userId, token);
        Object cachedResult = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachedResult != null) {
            return (JwtValidationResult) cachedResult;
        }
        
        // Cache miss - validate and populate cache
        return validateAndCache(token, userId);
    }
    
    private JwtValidationResult validateAndCache(String token, String userId) {
        // Validate token logic here
        JwtValidationResult result = validateTokenLogic(token, userId);
        
        // Cache the result with TTL
        String cacheKey = buildCacheKey(userId, token);
        redisTemplate.opsForValue().set(
            cacheKey, 
            result, 
            Duration.ofMinutes(15)
        );
        
        return result;
    }
    
    private String buildCacheKey(String userId, String token) {
        // Extract JTI from token or use token hash
        String jti = extractJtiFromToken(token);
        return String.format("jwt:access:%s:%s", userId, jti);
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The JWT token management system follows the repository pattern to abstract data access logic and provide a clean separation between domain logic and persistence concerns. Each entity has its own repository interface with standardized CRUD operations.

public interface JwtTokenRepository {
    Optional<JwtToken> findById(String tokenId);
    List<JwtToken> findByUserId(String userId);
    List<JwtToken> findByUserIdAndStatus(String userId, JwtTokenStatus status);
    JwtToken save(JwtToken token);
    void delete(String tokenId);
    void deleteByUserId(String userId);
    boolean existsById(String tokenId);
    long countByUserIdAndStatus(String userId, JwtTokenStatus status);
}

ORM vs. Raw Query Decisions

For JWT token management, we use JPA/Hibernate for most operations due to:

  • Type safety and compile-time validation
  • Built-in lazy loading and caching support
  • Easy implementation of soft-delete patterns
  • Standardized query building with JPQL/HQL

Raw queries are used for performance-critical operations like bulk token revocation or complex analytics queries.

Read vs. Write Separation

We implement CQRS pattern for JWT operations:

public class JwtTokenReadRepository {
    @Query("SELECT t FROM JwtToken t WHERE t.userId = :userId AND t.status = 'ACTIVE'")
    List<JwtToken> findActiveTokensByUserId(@Param("userId") String userId);
    
    @Query("SELECT COUNT(t) FROM JwtToken t WHERE t.userId = :userId AND t.status = 'ACTIVE'")
    long countActiveTokensByUserId(@Param("userId") String userId);
}

public class JwtTokenWriteRepository {
    @Modifying
    @Query("UPDATE JwtToken t SET t.status = 'REVOKED', t.revokedAt = :revokedAt WHERE t.id IN :tokenIds")
    void bulkRevokeTokens(@Param("tokenIds") List<String> tokenIds, @Param("revokedAt") Instant revokedAt);
}

Connection Pool Configuration

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      validation-timeout: 5000
      leak-detection-threshold: 60000

N+1 Query Prevention Strategy

We use eager loading for token relationships and batch fetching to prevent N+1 issues:

@Entity
@Table(name = "jwt_tokens")
@NamedEntityGraph(
    name = "JwtToken.withUser",
    attributeNodes = @NamedAttributeNode(value = "user", subgraph = "user-subgraph"),
    subgraphs = @NamedSubgraph(
        name = "user-subgraph",
        attributeNodes = @NamedAttributeNode("profile")
    )
)
public class JwtToken {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
    
    // ... other fields
}

Batch Operation Design

Bulk operations are implemented with batch size limits to prevent memory exhaustion:

@Repository
public class JwtTokenBatchRepository {
    
    @Modifying
    public void batchRevokeTokens(List<String> tokenIds, Instant revokedAt) {
        if (tokenIds.isEmpty()) return;
        
        int batchSize = 1000;
        for (int i = 0; i < tokenIds.size(); i += batchSize) {
            int endIndex = Math.min(i + batchSize, tokenIds.size());
            List<String> batch = tokenIds.subList(i, endIndex);
            
            String jpql = "UPDATE JwtToken t SET t.status = 'REVOKED', t.revokedAt = :revokedAt WHERE t.id IN :tokenIds";
            entityManager.createQuery(jpql)
                .setParameter("revokedAt", revokedAt)
                .setParameter("tokenIds", batch)
                .executeUpdate();
        }
    }
}

Transaction Management

All repository operations are wrapped in proper transaction boundaries:

@Repository
@Transactional
public class JwtTokenRepositoryImpl implements JwtTokenRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public JwtToken save(JwtToken token) {
        if (token.getId() == null) {
            entityManager.persist(token);
        } else {
            entityManager.merge(token);
        }
        return token;
    }
    
    @Override
    @Transactional(readOnly = true)
    public Optional<JwtToken> findById(String tokenId) {
        return Optional.ofNullable(entityManager.find(JwtToken.class, tokenId));
    }
}

Query Optimization

We implement query hints and index usage for performance:

@QueryHints(value = {
    @QueryHint(name = "org.hibernate.fetchSize", value = "50"),
    @QueryHint(name = "org.hibernate.timeout", value = "30")
})
@Query("SELECT t FROM JwtToken t WHERE t.userId = :userId AND t.status = 'ACTIVE' ORDER BY t.createdAt DESC")
List<JwtToken> findActiveTokensByUserId(@Param("userId") String userId);

Soft Delete Implementation

JWT tokens are soft-deleted to support audit trails:

@Entity
@Table(name = "jwt_tokens")
@Where(clause = "deleted_at IS NULL")
public class JwtToken {
    @Column(name = "deleted_at")
    private Instant deletedAt;
    
    @Column(name = "is_deleted")
    private boolean deleted = false;
    
    public void softDelete() {
        this.deleted = true;
        this.deletedAt = Instant.now();
    }
}

Data Access Layer Testing Strategy

@ExtendWith(MockitoExtension.class)
class JwtTokenRepositoryTest {
    
    @Mock
    private EntityManager entityManager;
    
    @InjectMocks
    private JwtTokenRepositoryImpl repository;
    
    @Test
    void shouldFindActiveTokensByUserId() {
        // Given
        String userId = "user-123";
        
        // When
        repository.findActiveTokensByUserId(userId);
        
        // Then
        verify(entityManager).createQuery(anyString());
    }
}

Performance Considerations

  1. Query caching: Frequently accessed token lookups are cached
  2. Connection pooling: Optimized pool sizes based on concurrent request patterns
  3. Batch processing: Bulk operations use batch size limits to prevent memory issues
  4. Indexing strategy: Proper indexes on userId, status, and expiration fields to support fast lookups

The repository layer is designed to be highly testable with proper mocking capabilities and follows clean architecture principles while maintaining performance at scale.

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The JWT token management system will use asynchronous processing for operations that don't require immediate response and can be processed in the background. The key async boundaries include:

  1. Token revocation tracking - When a token is revoked, we need to asynchronously update the token validation cache and notify other services that may have cached the token
  2. Token refresh operations - When a refresh token is used, we asynchronously invalidate old tokens and issue new ones
  3. Audit logging - Token creation, refresh, and revocation events are logged asynchronously to avoid blocking the main request flow
  4. Cleanup of expired tokens - Periodic cleanup of expired tokens from database and cache

The synchronous flows include token validation, generation, and immediate revocation which must return immediately to the caller.

Message schema design

{
  "version": "1.0",
  "event_type": "jwt_token_revoked",
  "timestamp": "2023-10-15T14:30:00Z",
  "payload": {
    "token_id": "uuid-1234567890",
    "user_id": "user-987654321",
    "revoked_at": "2023-10-15T14:30:00Z",
    "reason": "refresh_used"
  },
  "source": "jwt-service"
}

Dead letter queue strategy and poison message handling

For failed message processing, we'll implement a dead-letter queue (DLQ) strategy:

  1. Retry mechanism: Messages will be retried up to 3 times with exponential backoff (1s, 2s, 4s)
  2. DLQ handling: Messages that fail after max retries will be moved to a DLQ topic
  3. Poison message detection: Messages that fail consistently (same payload, same error) will be flagged for manual review
  4. Monitoring: DLQ queue size and failure rate will trigger alerts

At-least-once vs. exactly-once delivery

We'll use at-least-once delivery for JWT token management operations because:

  1. Token revocation and refresh are idempotent operations
  2. The system can safely process the same event multiple times without data inconsistency
  3. Simpler implementation and better performance characteristics

Retry policy for consumers

public class RetryPolicy {
    private static final int MAX_RETRIES = 3;
    private static final long INITIAL_DELAY_MS = 1000;
    private static final double BACKOFF_MULTIPLIER = 2.0;
    
    public boolean shouldRetry(int attempt, Exception exception) {
        if (attempt >= MAX_RETRIES) {
            return false;
        }
        
        // Don't retry on certain non-recoverable errors
        if (exception instanceof InvalidTokenException || 
            exception instanceof IllegalArgumentException) {
            return false;
        }
        
        return true;
    }
    
    public long getDelay(int attempt) {
        return (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt));
    }
}

Outbox pattern for reliable event publishing

We will implement the outbox pattern for JWT token events to ensure reliability:

  1. Transaction boundary: Events are written to an event_outbox table within the same database transaction as the business operation
  2. Event processor: A separate background service polls the outbox table and publishes messages to the message broker
  3. Idempotency: Events are marked as processed once successfully published to prevent duplicate delivery
  4. Failure handling: Failed events remain in the outbox for retry processing
@Entity
@Table(name = "event_outbox")
public class EventOutbox {
    @Id
    private UUID id;
    
    @Column(name = "event_type")
    private String eventType;
    
    @Lob
    @Column(name = "payload")
    private String payload;
    
    @Column(name = "status")
    private String status; // PENDING, PROCESSED, FAILED
    
    @Column(name = "retry_count")
    private int retryCount;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "processed_at")
    private LocalDateTime processedAt;
}

Sequence diagram for async flows

sequenceDiagram
    participant Client
    participant JWTService
    participant DB
    participant EventPublisher
    participant MessageBroker
    participant CacheService
    
    Client->>JWTService: Request token refresh
    JWTService->>DB: Validate refresh token
    JWTService->>DB: Create new access token
    JWTService->>DB: Invalidate old refresh token
    JWTService->>EventPublisher: Store event in outbox
    EventPublisher->>MessageBroker: Publish token_revoked event
    MessageBroker->>CacheService: Notify token invalidation
    CacheService-->>MessageBroker: Acknowledge
    MessageBroker->>EventPublisher: Confirm delivery
    EventPublisher-->>DB: Mark event as processed
    JWTService-->>Client: Return new tokens

10. Error Handling Design

Error Classification Hierarchy

The JWT system follows a three-tier error classification:

  1. Validation Errors (400-level): Invalid token format, missing claims, malformed JWT
  2. Authorization Errors (401/403): Expired tokens, insufficient permissions, revoked tokens
  3. Infrastructure Errors (500-level): Database failures, cache unavailability, external service timeouts
public class JwtError extends RuntimeException {
    private final JwtErrorCode code;
    private final int httpStatus;

    public JwtError(JwtErrorCode code, String message, int httpStatus) {
        super(message);
        this.code = code;
        this.httpStatus = httpStatus;
    }

    // Getters
}

public enum JwtErrorCode {
    INVALID_TOKEN_FORMAT(400, "Invalid JWT format"),
    MISSING_CLAIMS(400, "Required claims missing"),
    TOKEN_EXPIRED(401, "Token has expired"),
    TOKEN_REVOKED(401, "Token has been revoked"),
    INSUFFICIENT_PERMISSIONS(403, "Insufficient permissions"),
    DATABASE_ERROR(500, "Database operation failed"),
    CACHE_ERROR(500, "Cache operation failed");

    private final int httpStatus;
    private final String message;

    JwtErrorCode(int httpStatus, String message) {
        this.httpStatus = httpStatus;
        this.message = message;
    }
}

Error Propagation Strategy

Errors are propagated up the call stack with appropriate HTTP status codes. Service layer exceptions are converted to domain-specific JWT errors, while infrastructure errors are wrapped in system-level exceptions:

@Service
public class JwtTokenService {
    public JwtClaims validateToken(String token) throws JwtError {
        try {
            // Token validation logic
            return parseAndValidate(token);
        } catch (JwtException e) {
            throw new JwtError(JwtErrorCode.INVALID_TOKEN_FORMAT, 
                "Invalid JWT token format", 400);
        } catch (ExpiredJwtException e) {
            throw new JwtError(JwtErrorCode.TOKEN_EXPIRED, 
                "Token has expired", 401);
        } catch (Exception e) {
            throw new JwtError(JwtErrorCode.DATABASE_ERROR, 
                "Database operation failed", 500);
        }
    }
}

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, the system implements a retry mechanism:

@Retryable(
    maxAttempts = 3,
    backoff = @Backoff(
        multiplier = 2,
        random = true,
        delay = 1000,
        maxDelay = 10000
    ),
    include = {DatabaseException.class, CacheException.class}
)
public JwtClaims validateTokenWithRetry(String token) {
    return jwtTokenService.validateToken(token);
}

@Recover
public JwtClaims recoverFromRetry(DatabaseException ex, String token) {
    // Fallback to default claims or throw system error
    throw new JwtError(JwtErrorCode.DATABASE_ERROR, 
        "Failed to validate token after retries", 500);
}

Circuit Breaker Design

For external dependencies like Redis cache or database:

@Component
public class JwtCircuitBreaker {
    private final CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("jwt-cache");

    public <T> T executeWithCircuitBreaker(Supplier<T> operation) {
        return circuitBreaker.executeSupplier(operation);
    }

    public void onTokenValidationFailure() {
        circuitBreaker.recordFailure(new JwtError(JwtErrorCode.CACHE_ERROR, 
            "Cache validation failed", 500));
    }
}

Fallback Behavior Design

Fallbacks are implemented for critical dependencies:

public class JwtTokenService {
    private final CacheService cacheService;
    private final DatabaseService databaseService;

    public JwtClaims validateToken(String token) {
        try {
            return cacheService.get(token);
        } catch (CacheException e) {
            // Fallback to database lookup
            try {
                return databaseService.validateToken(token);
            } catch (DatabaseException fallbackEx) {
                // Last resort - return default claims or throw system error
                throw new JwtError(JwtErrorCode.DATABASE_ERROR, 
                    "Token validation failed - all fallbacks exhausted", 500);
            }
        }
    }
}

Error Logging Format and Alerting Thresholds

Structured logging includes trace context and error classification:

@Component
public class JwtErrorLogger {
    private static final Logger logger = LoggerFactory.getLogger(JwtErrorLogger.class);

    public void logJwtError(JwtError error, String userId, String traceId) {
        logger.error(
            "JWT_ERROR: code={}, message={}, userId={}, traceId={}, timestamp={}",
            error.getCode(),
            error.getMessage(),
            userId,
            traceId,
            Instant.now()
        );
    }
}

// Alerting thresholds
// - 5xx errors > 10 per minute → Critical alert
// - 401/403 errors > 100 per minute → Warning alert  
// - Cache miss rate > 5% → Warning alert

Error Response Format

Standardized JSON error response:

{
  "error": {
    "code": "TOKEN_EXPIRED",
    "message": "Token has expired",
    "httpStatus": 401,
    "timestamp": "2023-06-15T10:30:00Z",
    "traceId": "abc123def456"
  }
}

11. Security Design

Input Validation and Sanitization Strategy

All JWT token management endpoints must validate input parameters using a combination of:

  • Regex validation for token format (JWT structure: header.payload.signature)
  • Length limits (max 2048 characters for tokens)
  • Character set restrictions to prevent injection attacks
  • Custom validation rules using Apache Commons Validator or similar
public class JwtValidator {
    private static final Pattern JWT_PATTERN = 
        Pattern.compile("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$");
    
    public boolean isValidJwt(String token) {
        return token != null && 
               token.length() <= 2048 && 
               JWT_PATTERN.matcher(token).matches();
    }
}

SQL Injection Prevention

All database interactions use prepared statements with parameterized queries. No dynamic query construction or string concatenation is allowed.

Authentication Middleware Design

JWT authentication middleware intercepts all protected endpoints and:

  1. Extracts JWT from Authorization header (Bearer token format)
  2. Validates token signature using configured public key
  3. Checks token expiration and audience claims
  4. Sets authenticated user context in request scope

Authorization Enforcement

RBAC (Role-Based Access Control) is implemented at service layer:

  • Users have roles (admin, user, guest)
  • Each endpoint defines required roles in annotations
  • Service layer checks permissions before processing
@RequiredRole({"admin", "user"})
public void revokeToken(String tokenId) {
    // Authorization check happens in middleware
}

Sensitive Data Encryption at Rest and in Transit

  • At rest: AES-256 encryption for stored tokens using AWS KMS or Vault
  • In transit: TLS 1.3+ with HSTS header enforcement (HTTP Strict Transport Security)
  • Token storage: Encrypted in database using symmetric keys with key rotation

PII Handling and Masking Strategy

  • User identifiers in tokens are masked (e.g., user123 → user***)
  • Token metadata logs exclude sensitive fields like refresh tokens
  • All PII is encrypted or hashed before logging

Secrets Management

JWT signing keys and encryption keys are managed via:

  • AWS Secrets Manager (or equivalent in cloud provider)
  • Key rotation policy: 90-day automatic rotation with 30-day grace period
  • Access control: Principle of least privilege, IAM roles for services

Audit Logging Design

All token operations are logged with:

  • Event types: token_created, token_revoked, token_expired, token_used
  • Fields logged: user_id, token_id, timestamp, ip_address, user_agent, operation_type
  • Format: Structured JSON with trace_id and span_id for distributed tracing
  • Retention: 90 days with archiving to S3 for compliance
{
  "event_type": "token_revoked",
  "user_id": "user_12345",
  "token_id": "abc123def456",
  "timestamp": "2023-10-15T14:30:00Z",
  "ip_address": "192.168.1.100",
  "user_agent": "Mozilla/5.0...",
  "trace_id": "1234567890abcdef",
  "span_id": "abcdef1234567890"
}

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85%+ line coverage, 100% branch coverage for business logic paths
  • Repository Layer: 90%+ line coverage, focusing on query execution and error handling
  • Domain Objects: 100% for value objects, 95% for entities with complex validation logic
  • Security Components: 100% coverage for token validation, signature verification, and claims parsing
  • Exception Handling: 100% coverage for all error paths in service methods and API handlers

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL to test actual database interactions including transaction boundaries and constraint violations
  • External Service Mocks: WireMock for OAuth2 provider integration testing (token exchange, introspection endpoints)
  • Caching Layer: Embedded Redis or in-memory cache for validating cache behavior and invalidation strategies
  • Message Broker: Use embedded Kafka or RabbitMQ containers for async flow testing (event publishing, consumer handling)
  • Authentication Integration: Test JWT generation and validation against real cryptographic keys using JWK sets

Contract Testing

  • API Contract Testing: Use Pact framework to define consumer-driven contracts between JWT service and downstream services that consume tokens
  • Event Schema Contracts: Define Avro schemas with versioning strategy and validate consumer compatibility using Confluent Schema Registry
  • External API Contracts: Mock OAuth2 server responses with contract testing to ensure compatibility with third-party providers

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based load testing) for high-fidelity performance characterization
  • Key Scenarios:
    • Concurrent token generation (1000+ concurrent requests)
    • Token validation under load (5000 RPS with varied token sizes)
    • Cache hit ratio testing (95%+ cache hits expected)
    • Token refresh rate limiting under burst load
    • Memory usage during high-concurrency token generation (max heap usage < 500MB)

Test Data Management Strategy

  • Fixtures: Use JSON fixtures for common token claims, user roles, and permissions to ensure reproducible test states
  • Factories: Implement Factory pattern for generating valid JWT tokens with various configurations (expired, revoked, malformed)
  • Seeding: Use database seeders to populate test data including user accounts, roles, and permissions for authorization testing
  • Test Data Isolation: Each test runs in isolated database transaction with automatic rollback to prevent cross-test contamination
  • Data Generation: Use libraries like Faker for generating realistic but non-sensitive test data (usernames, emails, role names)

13. Observability Design

Structured Logging Schema

All logs must follow a structured format to enable efficient querying and analysis. The schema includes:

  • timestamp: ISO 8601 formatted timestamp
  • level: Log level (INFO, WARN, ERROR)
  • trace_id: OpenTelemetry trace identifier for distributed tracing correlation
  • span_id: OpenTelemetry span identifier
  • service_name: Service name (e.g., "jwt-token-service")
  • user_id: User identifier (masked for PII)
  • token_id: JWT token identifier (masked for PII)
  • operation: Operation name (e.g., "generateToken", "validateToken")
  • status_code: HTTP status code or internal operation result
  • error_code: Custom error code (e.g., "INVALID_TOKEN", "TOKEN_EXPIRED")
  • error_message: Human-readable error message (masked for PII)
  • duration_ms: Operation duration in milliseconds
  • request_id: Unique request identifier for correlation

PII masking strategy:

  • User IDs and token IDs are masked using a hash function with salt
  • Log fields containing sensitive information are redacted or truncated

Distributed Tracing Instrumentation

OpenTelemetry is used for distributed tracing with the following custom spans:

  1. Token Generation Span:

    • Operation: jwt.generate
    • Attributes:
      • token_type: "access" or "refresh"
      • user_id: masked
      • issuer: service name
  2. Token Validation Span:

    • Operation: jwt.validate
    • Attributes:
      • token_id: masked
      • validation_result: "valid", "invalid", "expired"
      • error_code: if validation fails
  3. Token Revocation Span:

    • Operation: jwt.revoke
    • Attributes:
      • token_id: masked
      • revoked_by: user or system identifier

Metrics Design

Service-level RED metrics are collected:

Metric NameTypeDescriptionLabels
jwt.token_generation_countCounterNumber of tokens generatedtoken_type, status_code
jwt.token_validation_countCounterNumber of validation attemptsvalidation_result, status_code
jwt.token_revocation_countCounterNumber of tokens revokedstatus_code
jwt.token_generation_duration_msHistogramToken generation latencytoken_type, status_code
jwt.token_validation_duration_msHistogramToken validation latencyvalidation_result, status_code
jwt.active_token_countGaugeCurrent active tokens in cachetoken_type

Health Check Endpoint Design

Three health check endpoints are implemented:

  1. /health:

    • Checks: Database connectivity, Redis connectivity, core service readiness
    • Response format:
      {
        "status": "healthy",
        "checks": {
          "database": {"status": "healthy", "message": "Connected"},
          "cache": {"status": "healthy", "message": "Connected"}
        }
      }
  2. /ready:

    • Checks: All dependencies ready, service can accept traffic
    • Response format:
      {
        "status": "ready",
        "timestamp": "2023-12-01T10:00:00Z",
        "dependencies": {
          "database": "healthy",
          "redis": "healthy"
        }
      }
  3. /live:

    • Checks: Service is alive and responding
    • Response format:
      {
        "status": "alive",
        "timestamp": "2023-12-01T10:00:00Z"
      }

Alerting Thresholds

Alerting thresholds are defined per key metric:

MetricWarning ThresholdCritical ThresholdAlert Channel
jwt.token_generation_duration_msp95 > 200msp99 > 500msOn-call
jwt.token_validation_duration_msp95 > 100msp99 > 300msOn-call
jwt.token_generation_countrate < 10/secrate < 5/secOn-call
jwt.token_validation_countrate < 100/secrate < 50/secOn-call
jwt.active_token_countcount > 100000count > 200000On-call
jwt.token_validation_count (failed)rate > 1/secrate > 5/secOn-call

Critical alerts trigger immediate on-call escalation, while warning alerts are logged and monitored for trend analysis.

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Token revocation strategy: Should we implement a centralized blacklist (e.g., Redis-based) or rely on short-lived tokens with refresh token rotation? This decision impacts system complexity, latency, and real-time revocation capabilities.
  • Refresh token storage: Should refresh tokens be stored in database or managed as JWTs with embedded metadata? This affects security posture and system scalability.
  • Cross-service token validation: For multi-service deployments, should we use a shared JWT signing key or implement a dedicated token validation service? This impacts system coupling and operational complexity.
  • Token audience (aud) and issuer (iss) configuration: Should these be configurable per service or standardized across the platform? This affects token portability and security boundaries.

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Token Expiration StrategyShort-lived access tokens (15-30 min) + refresh tokensLong-lived access tokens (24h+)Option 1 provides better security through shorter exposure windows but increases refresh token management overhead and user experience friction. Option 2 simplifies client logic but increases security risk surface.
Revocation MechanismRedis-based token blacklistStateless JWTs with fixed TTLsOption 1 enables real-time revocation but introduces external dependency and potential performance bottlenecks. Option 2 reduces system complexity but cannot revoke tokens immediately.
Database vs. In-Memory StorageStore refresh tokens in databaseKeep refresh tokens in memory (e.g., Redis)Option 1 provides persistence across system restarts and better auditability but introduces database I/O overhead. Option 2 offers better performance but requires careful handling of token persistence during system failures.
Token Validation ApproachClient-side validation with public keysServer-side validation with token store lookupOption 1 reduces server load but requires all services to have access to public keys. Option 2 centralizes validation logic but increases service coupling and potential single point of failure.

Risks and mitigation strategies

  • Token replay attacks: Risk of stolen tokens being used by unauthorized parties. Mitigation: Implement short token lifespans, enforce strict refresh token rotation, and use secure storage for tokens on client side.
  • Database failure impact: If the database storing refresh tokens becomes unavailable, users cannot refresh their tokens. Mitigation: Implement Redis-based caching layer with fallback to in-memory storage, and use circuit breaker patterns for database operations.
  • Token leakage through logs: Sensitive tokens logged in plain text. Mitigation: Implement structured logging with token field masking, and enforce strict log filtering policies.
  • Cross-site scripting (XSS) vulnerability: Client-side token storage exposed to XSS attacks. Mitigation: Use HttpOnly and Secure flags for cookies, implement Content Security Policy (CSP), and provide token storage in secure memory spaces.
  • Clock drift issues: JWT validation fails due to time differences between services. Mitigation: Implement clock skew tolerance (e.g., ±5 minutes) and synchronize system clocks using NTP.

Technical debt acknowledged

  • Legacy token format support: Backward compatibility with older JWT formats or non-standard claims may introduce complexity in validation logic and increase maintenance burden.
  • Hardcoded token configurations: Default token TTLs and refresh policies hardcoded in service configuration rather than being dynamically configurable via centralized config management.
  • Inconsistent token handling across services: Different services may implement token validation differently, leading to potential security gaps or inconsistent user experiences.
  • Limited audit trail for token operations: Current design lacks comprehensive logging of token issuance, refresh, and revocation events, making troubleshooting difficult in production environments.
  • No support for token binding: Tokens not bound to specific client IP addresses or user agents, which could increase attack surface in environments requiring strict session binding.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Frontend LLD

    Client-Side Authentication Manager Design

    Comprehensive low-level design for a secure, scalable client-side authentication system with token management and session handling.

  • Frontend LLD

    Automatic Token Refresh System Design

    Comprehensive low-level design for implementing automatic JWT token refresh in frontend applications with error handling and concurrency control

  • Frontend LLD

    JWT Token Management System Design

    Comprehensive low-level design for secure JWT token handling, refresh mechanisms, and session management in modern web applications.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.