This document presents a robust low-level architecture for managing API keys at scale, covering key generation, storage, validation, and lifecycle management with security best practices and performance optimizations.
1. Problem Statement & Requirements
Clarifying questions
- What scale expectations are there for this API Key Management system?
- Will it handle millions of keys, or is it more for enterprise customers with hundreds of thousands?
- What consistency requirements exist for key operations?
- Should key creation, revocation, and usage be strongly consistent or eventually consistent?
- What are the key entities in this system, and how do they relate?
- Is it just API keys, or does it include users, applications, permissions, and roles?
- What's the read/write ratio for API keys?
- Are most operations reads (checking key validity) or writes (creating/renewing keys)?
- What latency requirements are there for key validation?
- Should key checks complete in under 50ms for real-time API access?
- What authentication model is in use?
- Is it OAuth2, JWT, or custom API key-based authentication?
- Are there any external integrations or dependencies?
- Does this system integrate with an identity provider (e.g., Auth0, Okta) or external billing system?
Functional requirements
- API Key Creation: Generate new API keys with metadata (e.g., name, description, permissions).
- API Key Retrieval: Fetch details of an API key by ID or key value (masked for security).
- API Key Revocation: Disable or delete an API key.
- API Key Validation: Verify that a provided key is valid, active, and has appropriate permissions.
- API Key Rotation: Rotate keys by generating a new key while keeping the old one valid for a grace period.
- API Key Metadata Management: Update metadata (e.g., name, description) for existing keys.
- API Key Usage Tracking: Log usage statistics (e.g., last accessed, rate limits).
- Key Expiry Management: Automatically expire keys after a set time or upon user request.
Non-functional requirements
- Latency Targets:
- p50: < 20ms for key validation
- p95: < 50ms for key validation
- p99: < 100ms for key validation
- Throughput:
- Support at least 50,000 key validations per second
- Availability:
- 99.9% uptime (438 minutes of downtime per year)
- Security:
- Keys must be securely stored and transmitted
- Keys must not be exposed in logs or responses
Constraints and assumptions
- Assumption: API keys are used for authentication and authorization to a protected API.
- Constraint: Keys must be generated with sufficient entropy to prevent brute-force attacks.
- Assumption: The system is part of a larger microservices ecosystem with shared authentication infrastructure.
- Constraint: Keys must support rate limiting and usage tracking for security and billing purposes.
Out of scope
- User Management: Not part of this system; users are managed elsewhere (e.g., Identity Provider).
- Key Encryption at Rest: This is handled by the underlying database or storage layer.
- API Key Distribution: Not part of this system; keys are distributed to clients via secure channels.
- Key Sharing or Delegation: Keys cannot be shared or delegated to other users or services.
- Audit Logs for Key Changes: This is handled by a separate audit logging system.
- Multi-tenant Support: The system assumes a single tenant or namespace for all keys.
2. API Design
API Style Selection
gRPC will be selected for this system due to:
- Strong typing and auto-generated clients in multiple languages
- Efficient binary protocol with compression support
- Built-in authentication and authorization mechanisms
- Native support for streaming operations (useful for key rotation notifications)
- Better performance characteristics for high-throughput scenarios
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| CreateKey | POST /v1/keys | Creates a new API key for a user | JWT |
| GetKey | GET /v1/keys/{keyId} | Retrieves details of a specific API key | JWT |
| ListKeys | GET /v1/keys | Lists all API keys for a user | JWT |
| UpdateKey | PUT /v1/keys/{keyId} | Updates metadata of an API key | JWT |
| DeleteKey | DELETE /v1/keys/{keyId} | Soft-deletes an API key | JWT |
| RotateKey | POST /v1/keys/{keyId}/rotate | Rotates the secret of an API key | JWT |
| ValidateKey | POST /v1/keys/validate | Validates an API key for use | None |
Request and Response Schemas
CreateKeyRequest
{
"name": "string",
"description": "string",
"scopes": ["read", "write"],
"expiresAt": "2024-12-31T23:59:59Z"
}
CreateKeyResponse
{
"keyId": "uuid",
"keySecret": "string",
"name": "string",
"description": "string",
"scopes": ["read", "write"],
"createdAt": "2024-12-31T23:59:59Z",
"expiresAt": "2024-12-31T23:59:59Z",
"status": "active"
}
GetKeyResponse
{
"keyId": "uuid",
"name": "string",
"description": "string",
"scopes": ["read", "write"],
"createdAt": "2024-12-31T23:59:59Z",
"expiresAt": "2024-12-31T23:59:59Z",
"status": "active"
}
ListKeysResponse
{
"keys": [
{
"keyId": "uuid",
"name": "string",
"description": "string",
"scopes": ["read", "write"],
"createdAt": "2024-12-31T23:59:59Z",
"expiresAt": "2024-12-31T23:59:59Z",
"status": "active"
}
],
"nextPageToken": "string"
}
ValidateKeyRequest
{
"keyId": "uuid",
"keySecret": "string"
}
ValidateKeyResponse
{
"valid": true,
"keyId": "uuid",
"name": "string",
"scopes": ["read", "write"],
"expiresAt": "2024-12-31T23:59:59Z"
}
HTTP Status Codes and Error Format
Standard Status Codes:
- 200 OK - Successful GET, PUT, PATCH, DELETE
- 201 Created - Successful POST (create)
- 400 Bad Request - Invalid input
- 401 Unauthorized - Missing or invalid auth token
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Unexpected server error
Standardized Error Response Format:
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "The provided key name is too long",
"details": [
{
"field": "name",
"message": "Name must be between 1 and 50 characters"
}
]
}
}
API Versioning Strategy
Version will be handled through the path prefix: /v1/keys. This allows for:
- Clear separation between versions
- Easy migration path for clients
- Backward compatibility support during transition
Pagination Design
Cursor-based pagination will be used because:
- More efficient for large datasets
- Handles data changes during pagination better
- Provides consistent results even when items are inserted/deleted
Authentication and Authorization
Authentication:
- JWT tokens with HS256 signing
- Token includes user_id and scope claims
Authorization:
- RBAC model with roles (admin, user)
- Each API key belongs to a specific user
- Users can only access their own keys
Rate Limiting Per Endpoint
| Endpoint | Limit | Period | Enforcement |
|---|---|---|---|
| CreateKey | 100/minute | 1 minute | In-memory + Redis |
| GetKey | 1000/minute | 1 minute | In-memory + Redis |
| ListKeys | 500/minute | 1 minute | In-memory + Redis |
| UpdateKey | 100/minute | 1 minute | In-memory + Redis |
| DeleteKey | 50/minute | 1 minute | In-memory + Redis |
| RotateKey | 100/minute | 1 minute | In-memory + Redis |
| ValidateKey | 5000/minute | 1 minute | In-memory + Redis |
Idempotency Design
For mutation endpoints, idempotency keys will be supported:
- Clients send
Idempotency-Keyheader with each request - Server stores the operation result for that key
- If same key is used, return stored result instead of reprocessing
// Example usage in gRPC service
public CreateKeyResponse createKey(CreateKeyRequest request, String idempotencyKey) {
if (idempotencyKey != null) {
CreateKeyResponse cached = idempotencyCache.get(idempotencyKey);
if (cached != null) {
return cached;
}
}
// Process request...
CreateKeyResponse response = processCreateKey(request);
if (idempotencyKey != null) {
idempotencyCache.put(idempotencyKey, response);
}
return response;
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
USER ||--o{ API_KEY : "owns"
API_KEY ||--o{ API_KEY_USAGE : "records"
API_KEY ||--o{ API_KEY_PERMISSION : "grants"
PERMISSION ||--o{ API_KEY_PERMISSION : "defines"
API_KEY_USAGE ||--o{ EVENT_LOG : "logs"
USER {
string id PK
string email
string name
timestamp created_at
timestamp updated_at
}
API_KEY {
string id PK
string user_id FK
string name
string key_hash
string key_prefix
string status
timestamp created_at
timestamp updated_at
timestamp expires_at
timestamp last_used_at
}
PERMISSION {
string id PK
string name
string description
timestamp created_at
}
API_KEY_PERMISSION {
string api_key_id FK
string permission_id FK
timestamp created_at
}
API_KEY_USAGE {
string id PK
string api_key_id FK
string endpoint
string method
timestamp accessed_at
string ip_address
string user_agent
}
EVENT_LOG {
string id PK
string api_key_id FK
string event_type
string details
timestamp logged_at
}
Table Schema Definitions
users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
api_keys table
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_hash BYTEA NOT NULL,
key_prefix VARCHAR(10) NOT NULL,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'revoked')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
last_used_at TIMESTAMP,
UNIQUE(user_id, key_prefix)
);
permissions table
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
api_key_permissions table
CREATE TABLE api_key_permissions (
api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (api_key_id, permission_id)
);
api_key_usage table
CREATE TABLE api_key_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE,
endpoint VARCHAR(255),
method VARCHAR(10),
accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address INET,
user_agent TEXT
);
event_logs table
CREATE TABLE event_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE,
event_type VARCHAR(50),
details JSONB,
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Primary Key Strategy
UUIDs chosen over auto-increment for the following reasons:
- Global uniqueness: UUIDs ensure uniqueness across distributed systems without coordination
- Security: Sequential IDs can expose system information to attackers
- Scalability: No need for centralized ID generation in multi-region deployments
- No hotspots: Avoids write contention on sequential ID generators
Index Design
| Index Name | Fields | Type | Purpose |
|---|---|---|---|
| idx_api_keys_user_id | user_id | Standard | Fast lookup by user |
| idx_api_keys_key_prefix | key_prefix | Unique | Fast lookup by key prefix |
| idx_api_keys_status | status | Standard | Filter active keys |
| idx_api_keys_expires_at | expires_at | Partial (WHERE expires_at > NOW()) | Efficient expiration checks |
| idx_api_key_permissions_api_key_id | api_key_id | Standard | Fast permission lookup |
| idx_api_key_usage_api_key_id | api_key_id | Standard | Fast usage tracking |
| idx_api_key_usage_accessed_at | accessed_at | Standard | Time-based queries |
| idx_event_logs_api_key_id | api_key_id | Standard | Audit trail lookup |
Data Partitioning / Sharding Strategy
Sharding by user_id hash (consistent hashing):
- Partition key:
hash(user_id) % NUMBER_OF_SHARDS - Benefits:
- Even distribution of API keys
- Predictable sharding for read-heavy workloads
- Easy to add/remove shards with minimal data movement
Soft Delete vs. Hard Delete Decision
Soft delete chosen for API keys with the following rationale:
- Audit compliance: Historical tracking of revoked keys required for security audits
- Recovery capability: Ability to restore keys in case of accidental revocation
- Usage analytics: Historical usage patterns need to be preserved for analysis
- Compliance: GDPR and other regulations may require data retention
Audit Trail Design
Event logging table structure:
-- Event types include: KEY_CREATED, KEY_REVOKED, KEY_EXPIRED, USAGE_ACCESS, PERMISSION_CHANGED
-- Fields: event_type (string), details (JSONB with metadata), logged_at (timestamp)
Audit fields captured:
- Who (user_id, api_key_id)
- What (event_type, details)
- When (logged_at)
- Where (IP address, user agent)
Data Archival Strategy
Archival policy:
- Active keys: Retained indefinitely with full audit trail
- Revoked keys: Retained for 90 days (compliance requirement)
- Usage logs: Retained for 30 days (usage analytics)
- Event logs: Retained for 180 days (compliance and security)
Archival process:
- Daily batch job moves expired data to archive tables
- Archive tables use different storage (e.g., S3 with lifecycle policies)
- Metadata maintained in main tables for referential integrity
4. Class Design & Service Layer
classDiagram
class ApiKeyService {
+createApiKey(CreateApiKeyRequest) ApiKeyResponse
+getApiKey(String) ApiKeyResponse
+revokeApiKey(String) void
+listApiKeys(ListApiKeysRequest) ApiKeyListResponse
+rotateApiKey(String) ApiKeyResponse
}
class ApiKeyRepository {
+save(ApiKey) void
+findById(String) Optional~ApiKey~
+findByUserIdAndName(String, String) Optional~ApiKey~
+findByUserId(String) List~ApiKey~
+update(ApiKey) void
+delete(String) void
}
class ApiKey {
-id: String
-userId: String
-name: String
-key: String
-status: ApiKeyStatus
-createdAt: Instant
-expiresAt: Instant
-lastUsedAt: Instant
-metadata: Map~String, String~
+generateKey() String
+isExpired() boolean
+isActive() boolean
}
class ApiKeyValidator {
+validate(ApiKey) boolean
+validatePermissions(ApiKey, String) boolean
}
class EventPublisher {
+publish(ApiKeyEvent) void
}
class ApiKeyEvent {
-eventType: String
-apiKeyId: String
-userId: String
-timestamp: Instant
}
class ApiKeyGenerator {
+generateKey() String
}
class AuditLogger {
+log(AuditEvent) void
}
class AuditEvent {
-userId: String
-action: String
-resourceId: String
-timestamp: Instant
-details: Map~String, String~
}
ApiKeyService --> ApiKeyRepository : uses
ApiKeyService --> ApiKeyValidator : validates
ApiKeyService --> EventPublisher : publishes events
ApiKeyService --> AuditLogger : logs audit
ApiKeyService --> ApiKeyGenerator : generates keys
ApiKey <-- ApiKeyRepository : returns
ApiKeyValidator --> ApiKey : validates
EventPublisher --> ApiKeyEvent : publishes
AuditLogger --> AuditEvent : logs
stateDiagram-v2
[*] --> Active
Active --> Revoked : revoke()
Active --> Expired : expires_at < now
Revoked --> Active : rotate()
Expired --> Active : rotate()
Active --> Expired : expires_at < now
Service Class Structure and Responsibilities
The API Key Management system is designed with a clean separation of concerns:
- ApiKeyService - Core business logic layer handling key lifecycle operations
- ApiKeyRepository - Data access abstraction for persistence operations
- ApiKeyValidator - Input validation and permission checking
- EventPublisher - Event-driven system integration
- AuditLogger - Audit trail and compliance logging
Interface / Abstract Class Design
public interface ApiKeyRepository {
void save(ApiKey apiKey);
Optional<ApiKey> findById(String id);
Optional<ApiKey> findByUserIdAndName(String userId, String name);
List<ApiKey> findByUserId(String userId);
void update(ApiKey apiKey);
void delete(String id);
}
public interface EventPublisher {
void publish(ApiKeyEvent event);
}
public enum ApiKeyStatus {
ACTIVE, REVOKED, EXPIRED
}
Business Logic Encapsulation Patterns
Strategy Pattern for key generation:
public interface ApiKeyGenerationStrategy {
String generate();
}
public class SecureRandomApiKeyStrategy implements ApiKeyGenerationStrategy {
private static final int KEY_LENGTH = 32;
@Override
public String generate() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[KEY_LENGTH];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
public class ApiKeyGenerator {
private final ApiKeyGenerationStrategy strategy;
public ApiKeyGenerator(ApiKeyGenerationStrategy strategy) {
this.strategy = strategy;
}
public String generateKey() {
return strategy.generate();
}
}
Critical-Path Method Implementation
@Transactional
public ApiKeyResponse createApiKey(CreateApiKeyRequest request) {
// 1. Validate input and permissions
if (!apiKeyValidator.validatePermissions(request.getUserId(), "CREATE_API_KEY")) {
throw new UnauthorizedException("Insufficient permissions to create API key");
}
// 2. Generate unique key name if not provided
String keyName = Optional.ofNullable(request.getName())
.orElse("key-" + System.currentTimeMillis());
// 3. Check for existing key with same name
if (apiKeyRepository.findByUserIdAndName(request.getUserId(), keyName).isPresent()) {
throw new ConflictException("API key name already exists for user");
}
// 4. Generate and persist new API key
String apiKeyValue = apiKeyGenerator.generateKey();
ApiKey apiKey = new ApiKey(
UUID.randomUUID().toString(),
request.getUserId(),
keyName,
apiKeyValue,
ApiKeyStatus.ACTIVE,
Instant.now(),
request.getExpiresAt(),
null,
request.getMetadata()
);
apiKeyRepository.save(apiKey);
// 5. Publish event for audit and monitoring
ApiKeyEvent event = new ApiKeyEvent(
"API_KEY_CREATED",
apiKey.getId(),
apiKey.getUserId(),
Instant.now()
);
eventPublisher.publish(event);
// 6. Log audit trail
AuditEvent audit = new AuditEvent(
apiKey.getUserId(),
"CREATE_API_KEY",
apiKey.getId(),
Instant.now(),
Map.of("keyName", keyName, "expiresAt", apiKey.getExpiresAt().toString())
);
auditLogger.log(audit);
// 7. Return response with key (only public part in response)
return new ApiKeyResponse(
apiKey.getId(),
apiKey.getName(),
apiKey.getUserId(),
apiKey.getStatus(),
apiKey.getCreatedAt(),
apiKey.getExpiresAt(),
apiKey.getMetadata()
);
}
Transaction Boundary Design
A single transaction encompasses:
- API key creation validation
- Key generation and persistence
- Event publishing (if using same DB session)
- Audit logging
For distributed systems, we use the outbox pattern to ensure eventual consistency between database writes and event publishing.
Service Interface Contracts
public interface ApiKeyService {
ApiKeyResponse createApiKey(CreateApiKeyRequest request);
ApiKeyResponse getApiKey(String apiKeyId);
void revokeApiKey(String apiKeyId);
ApiKeyListResponse listApiKeys(ListApiKeysRequest request);
ApiKeyResponse rotateApiKey(String apiKeyId);
}
Saga / Orchestration Pattern
For complex operations like key rotation involving multiple systems:
- If external service integrations exist (e.g., IAM sync), use saga pattern
- For local operations only, single transaction is sufficient
Domain Object / Value Object Design
public class ApiKey {
private final String id;
private final String userId;
private final String name;
private final String key; // Only stored as hash in DB
private ApiKeyStatus status;
private final Instant createdAt;
private Instant expiresAt;
private Instant lastUsedAt;
private final Map<String, String> metadata;
// Constructor, getters, and immutable methods
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public boolean isActive() {
return status == ApiKeyStatus.ACTIVE && !isExpired();
}
public String getPublicId() {
return id.substring(0, 8) + "...";
}
}
The design follows clean architecture principles with clear separation between domain logic, data access, and external integrations. The critical-path method demonstrates proper transaction handling, validation, event publishing, and audit logging in a single coherent operation.
5. Concurrency & Thread Safety
Concurrency Model
The API Key Management system will use a thread-safe, asynchronous processing model with:
- Java's
CompletableFuturefor non-blocking operations - Thread pools with fixed-size executors for parallel processing of key operations
- Reactive programming patterns where appropriate (e.g., using Project Reactor for async flows)
- Single-threaded event loop for critical atomic operations (e.g., key generation, revocation)
Race Condition Prevention Strategy
Race conditions will be prevented through:
- Atomic operations using
java.util.concurrent.atomicpackages for counters and flags - Database-level constraints (unique constraints, foreign key constraints)
- Optimistic locking with version fields for concurrent updates
- Database transactions with proper isolation levels (READ_COMMITTED or SERIALIZABLE where needed)
- Synchronized blocks for critical sections in service layer
Optimistic vs. Pessimistic Locking Selection
-
Optimistic locking is selected for most operations due to:
- High read-to-write ratio in API key management
- Low contention scenarios (keys are typically not updated frequently)
- Better performance under moderate concurrency
- Reduced lock wait times and improved throughput
-
Pessimistic locking is used for:
- Key generation operations where uniqueness must be guaranteed
- Revocation operations that require exclusive access to prevent concurrent modifications
Deadlock Prevention Strategy
- Lock ordering: All database operations follow a consistent order (e.g., always acquire user lock before key lock)
- Timeout configuration: Set 5-second timeouts on database locks to prevent indefinite blocking
- Hierarchical locking: Use a top-down approach where parent entities are locked before child entities
- Deadlock detection: Leverage database-level deadlock detection and automatic retry mechanisms
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented through:
- Idempotency keys in request headers:
// Example header: X-Idempotency-Key: abc123def456
public class IdempotencyService {
private final Map<String, String> idempotencyCache = new ConcurrentHashMap<>();
public boolean isIdempotentRequest(String key, String operation) {
return idempotencyCache.putIfAbsent(key, operation) != null;
}
}
- Database-level idempotency tracking:
// Store operation metadata in a separate table with composite primary key (idempotency_key, operation_type)
public class IdempotencyRecord {
private String idempotencyKey;
private String operationType;
private String result;
private LocalDateTime createdAt;
}
- Request deduplication service:
@Service
public class IdempotencyService {
private final IdempotencyRepository idempotencyRepo;
public ApiResponse<?> processWithIdempotency(String idempotencyKey,
Supplier<ApiResponse<?>> operation) {
IdempotencyRecord record = idempotencyRepo.findByIdempotencyKey(idempotencyKey);
if (record != null) {
return ApiResponse.success(record.getResult());
}
ApiResponse<?> result = operation.get();
idempotencyRepo.save(new IdempotencyRecord(idempotencyKey, operation.toString(), result));
return result;
}
}
Thread Safety in Service Layer
All service methods are designed to be thread-safe:
- Immutable objects for configuration and metadata
- Thread-safe collections (ConcurrentHashMap, CopyOnWriteArrayList)
- Synchronized access to shared mutable state
- Stateless service methods where possible
Concurrency Control in Key Operations
- Key generation: Uses atomic counters and database sequences to ensure uniqueness
- Key retrieval: Read operations are not synchronized, but cached with proper TTL
- Key revocation: Uses optimistic locking to prevent concurrent revocations
- Key rotation: Synchronized to ensure atomicity of key replacement operations
Performance Considerations
- Read-heavy workload: Optimistic locking with caching minimizes lock contention
- Write operations: Use batch processing where possible to reduce transaction overhead
- Database connection pooling: Configured with proper pool sizes (e.g., 20-50 connections) to handle concurrent requests
- Async processing: Non-blocking operations for key validation and audit logging
Testing Concurrency Scenarios
- Stress testing with concurrent API key creation/retrieval operations
- Race condition tests using JUnit's
@Testwith multiple threads - Database isolation level testing to ensure proper locking behavior
- Deadlock detection using database monitoring tools during load testing
This concurrency model ensures high throughput while maintaining data consistency and preventing race conditions in a multi-threaded environment.
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant U as User/Developer
participant API as API Gateway
participant AuthS as Auth Service
participant DB as Database
U->>API: Request with API Key
API->>AuthS: Validate API Key
AuthS->>DB: Lookup API Key
DB-->>AuthS: Return API Key details
AuthS-->>API: Validate result (success/failure)
alt Valid Key
API->>U: Forward request to target service
else Invalid Key
API-->>U: Return 401 Unauthorized
end
sequenceDiagram
participant U as User/Developer
participant AuthS as Auth Service
participant DB as Database
participant EventQ as Event Queue
U->>AuthS: Create API Key (POST /api-keys)
AuthS->>DB: Generate and store API Key
DB-->>AuthS: Return stored key details
AuthS->>EventQ: Publish API_KEY_CREATED event
EventQ-->>AuthS: Acknowledge event
AuthS-->>U: Return created API Key with secret
sequenceDiagram
participant U as User/Developer
participant AuthS as Auth Service
participant DB as Database
participant EventQ as Event Queue
U->>AuthS: Revoke API Key (DELETE /api-keys/{keyId})
AuthS->>DB: Mark key as revoked
DB-->>AuthS: Confirm revocation
AuthS->>EventQ: Publish API_KEY_REVOKED event
EventQ-->>AuthS: Acknowledge event
AuthS-->>U: Return success response
7. Caching Design
Cache Strategy Selection
For API Key Management system, we'll use cache-aside pattern with write-through behavior for critical data and write-behind for audit logs. This provides optimal balance between consistency and performance.
Cache Key Design
Cache keys will follow a structured namespace pattern:
api-key:{keyId}
api-key:by-identifier:{identifier}
api-key:by-user:{userId}
api-key:by-app:{appId}
api-key:active:{keyId}
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Active API Keys | 1 hour | Frequent access, need to refresh periodically |
| Key Metadata | 24 hours | Less volatile, infrequent updates |
| User Permissions | 15 minutes | Security sensitive, frequent refresh |
| Rate Limits | 1 minute | High frequency, short-lived |
| Audit Logs | 30 days | Compliance requirements |
Cache Eviction Policy
Implement LRU (Least Recently Used) eviction policy with:
- TTL-based expiration for automatic cleanup
- Size-based limits (500MB max per cache node)
- Priority eviction for critical keys
Cache Stampede Prevention
Use probabilistic early expiry:
public class CacheStampedeProtection {
private static final double EXPIRY_FUDGE_FACTOR = 0.8;
public String getWithStampedeProtection(String key, Supplier<String> loader) {
String cached = cache.get(key);
if (cached != null) {
return cached;
}
// Try to acquire lock for cache population
if (lockManager.tryAcquire(key, 500)) {
try {
// Double-check pattern
String fresh = cache.get(key);
if (fresh != null) {
return fresh;
}
// Load fresh data with early expiry
String value = loader.get();
cache.put(key, value,
(long) (getTTL(key) * EXPIRY_FUDGE_FACTOR));
return value;
} finally {
lockManager.release(key);
}
}
// Fallback to stale cache or wait
return cache.get(key);
}
}
Distributed Cache Architecture
Using Redis Cluster with:
- 3 master nodes for high availability
- 3 replica nodes per master for read scaling
- Redis Sentinel for automatic failover
- Cluster sharding based on key namespace
Cache Invalidation Strategy
Implement event-driven invalidation:
- Immediate invalidation on key creation/update/deletion
- Batch invalidation for bulk operations (e.g., app key rotation)
- TTL-based cleanup for soft-expired entries
@Component
public class ApiKeyCacheInvalidator {
private final RedisTemplate<String, String> redisTemplate;
public void invalidateKey(String keyId) {
// Remove from all relevant cache namespaces
String[] keysToInvalidate = {
"api-key:" + keyId,
"api-key:by-identifier:" + getIdentifier(keyId),
"api-key:by-user:" + getUserForKey(keyId)
};
// Batch delete for efficiency
redisTemplate.delete(Arrays.asList(keysToInvalidate));
// Publish invalidation event for distributed systems
eventPublisher.publish(new CacheInvalidationEvent("api-key", keyId));
}
}
Cache Hit/Miss Ratio Optimization
Monitor and optimize:
- Hit ratio target: >90% for active keys
- Miss ratio analysis: Identify hot keys that need warming
- Warm-up strategy: Pre-load frequently accessed keys during startup
Cache Read/Write Separation
Separate cache read and write operations:
public class ApiKeyCacheService {
private final CacheReadService readService;
private final CacheWriteService writeService;
public ApiKeyResponse getApiKey(String keyId) {
// Read from cache first
return readService.getFromCache(keyId);
}
public void updateApiKey(ApiKey key) {
// Write through to cache and database
writeService.updateCache(key);
databaseService.save(key);
}
}
This caching strategy ensures optimal performance while maintaining data consistency and providing resilience against cache failures. The design balances the need for low-latency API key validation with the requirement for strong consistency in security-critical operations.
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and database operations. Each entity has its own repository interface with standardized CRUD operations.
public interface ApiKeyRepository {
Optional<ApiKey> findById(UUID id);
Optional<ApiKey> findByKey(String key);
List<ApiKey> findByOwnerId(UUID ownerId);
Page<ApiKey> findByOwnerId(UUID ownerId, Pageable pageable);
ApiKey save(ApiKey apiKey);
void delete(UUID id);
boolean existsById(UUID id);
}
public interface ApiKeyAuditRepository {
void save(AuditLog auditLog);
List<AuditLog> findByApiKeyId(UUID apiKeyId, Pageable pageable);
}
ORM vs Raw Query Decisions
For the core API key management system, we use JPA/Hibernate as the ORM for its type safety and ease of development. However, for high-volume read operations like key validation, raw SQL queries are preferred to minimize overhead and maximize performance.
@Repository
public class ApiKeyRepositoryImpl implements ApiKeyRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public Optional<ApiKey> findByKey(String key) {
// Raw SQL for performance-critical validation
String sql = "SELECT * FROM api_keys WHERE key_hash = :keyHash AND status = 'ACTIVE'";
TypedQuery<ApiKey> query = entityManager.createNativeQuery(sql, ApiKey.class);
query.setParameter("keyHash", hashKey(key));
return Optional.ofNullable(query.getSingleResultOrNull());
}
@Override
public ApiKey save(ApiKey apiKey) {
// JPA for complex domain object persistence
return entityManager.merge(apiKey);
}
}
Read vs Write Separation
The system implements CQRS (Command Query Responsibility Segregation) pattern for the API key management to optimize read and write operations:
// Write-side repository - handles mutations
@Repository
public class ApiKeyWriteRepository {
@PersistenceContext
private EntityManager entityManager;
public ApiKey save(ApiKey apiKey) {
return entityManager.merge(apiKey);
}
public void delete(UUID id) {
entityManager.remove(entityManager.find(ApiKey.class, id));
}
}
// Read-side repository - optimized for queries
@Repository
public class ApiKeyReadRepository {
@PersistenceContext
private EntityManager entityManager;
public List<ApiKeyView> findAllActiveKeys(UUID ownerId) {
String jpql = """
SELECT new com.example.apikey.ApiKeyView(
ak.id, ak.name, ak.ownerId, ak.status, ak.createdAt
) FROM ApiKey ak WHERE ak.ownerId = :ownerId AND ak.status = 'ACTIVE'
""";
return entityManager.createQuery(jpql, ApiKeyView.class)
.setParameter("ownerId", ownerId)
.getResultList();
}
}
Connection Pool Configuration
Connection pool is configured with appropriate sizing and validation to handle concurrent API key operations efficiently:
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
validation-timeout: 5000
idle-timeout: 600000
max-lifetime: 1800000
N+1 Query Prevention Strategy
To prevent N+1 query problems, eager loading is used for related entities and batch operations are implemented:
@Entity
@Table(name = "api_keys")
@NamedEntityGraph(
name = "ApiKey.withOwner",
attributeNodes = @NamedAttributeNode(value = "owner", subgraph = "owner-subgraph"),
subgraphs = @NamedSubgraph(
name = "owner-subgraph",
attributeNodes = @NamedAttributeNode("user")
)
)
public class ApiKey {
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "owner_id")
private User owner;
// ... other fields and methods
}
// In service layer, use fetch joins to prevent N+1:
public List<ApiKey> getActiveKeysWithOwner(UUID ownerId) {
String jpql = "SELECT ak FROM ApiKey ak JOIN FETCH ak.owner WHERE ak.ownerId = :ownerId AND ak.status = 'ACTIVE'";
return entityManager.createQuery(jpql, ApiKey.class)
.setParameter("ownerId", ownerId)
.getResultList();
}
Batch Operation Design
Batch operations are implemented for bulk API key creation and updates:
@Repository
public class BatchApiKeyRepository {
public void batchCreate(List<ApiKey> apiKeys) {
String sql = """
INSERT INTO api_keys (id, key_hash, name, owner_id, status, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
for (ApiKey key : apiKeys) {
stmt.setObject(1, key.getId());
stmt.setString(2, key.getKeyHash());
stmt.setString(3, key.getName());
stmt.setObject(4, key.getOwnerId());
stmt.setString(5, key.getStatus().name());
stmt.setTimestamp(6, Timestamp.from(key.getCreatedAt()));
stmt.addBatch();
}
stmt.executeBatch();
} catch (SQLException e) {
throw new DataAccessException("Failed to batch create API keys", e);
}
}
}
Transaction Management
Transaction boundaries are carefully defined to ensure data consistency:
@Service
@Transactional
public class ApiKeyService {
@Autowired
private ApiKeyRepository apiKeyRepository;
@Autowired
private ApiKeyAuditRepository auditRepository;
public ApiKey createApiKey(ApiKeyRequest request) {
// Single transaction boundary for key creation and audit logging
ApiKey apiKey = new ApiKey();
apiKey.setId(UUID.randomUUID());
apiKey.setKeyHash(hashKey(request.getKey()));
apiKey.setName(request.getName());
apiKey.setOwnerId(request.getOwnerId());
apiKey.setStatus(ApiKeyStatus.ACTIVE);
apiKey.setCreatedAt(Instant.now());
ApiKey saved = apiKeyRepository.save(apiKey);
// Audit log within same transaction
AuditLog audit = new AuditLog();
audit.setApiKeyId(saved.getId());
audit.setAction("CREATE");
audit.setActorId(request.getOwnerId());
audit.setTimestamp(Instant.now());
auditRepository.save(audit);
return saved;
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The API Key Management system will use asynchronous processing for operations that don't require immediate response to the client and can be safely decoupled from the primary request flow. Key boundaries include:
- Key rotation and expiration notifications - When an API key is rotated or expires, the system asynchronously notifies related services (e.g., authentication service, audit logging)
- Usage analytics aggregation - Periodic batch processing of key usage metrics for reporting and monitoring
- Compliance audit triggers - When a key is revoked or modified, async processing can trigger compliance checks with external systems
- Cross-service synchronization - When keys are updated across multiple services (e.g., in a multi-region deployment), async reconciliation ensures consistency
Synchronous operations remain for direct key creation, retrieval, and immediate validation to ensure low-latency responses for authentication flows.
Message schema design
{
"version": "1.0",
"event_type": "api_key_rotated",
"timestamp": "2023-10-15T14:30:00Z",
"source": "api-key-service",
"payload": {
"key_id": "ak-1234567890abcdef",
"new_key_hash": "sha256:abc123...",
"previous_key_hash": "sha256:def456...",
"owner_id": "user-9876543210fedcba",
"tenant_id": "tenant-1234567890",
"expires_at": "2024-10-15T14:30:00Z",
"audit_context": {
"actor": "system",
"reason": "automated_rotation"
}
},
"correlation_id": "corr-uuid-1234567890"
}
Dead letter queue strategy and poison message handling
Poison messages will be routed to a dead-letter queue (DLQ) after 3 failed delivery attempts. The system will:
- Track failed deliveries with exponential backoff (5s, 10s, 20s)
- Move messages to DLQ after max retries (3 attempts)
- Log detailed error information including the original payload and error stack trace
- Alert on DLQ message accumulation with threshold-based monitoring (e.g., >10 messages in 5 minutes)
- Manual intervention process for DLQ message inspection and reprocessing
At-least-once vs. exactly-once delivery
At-least-once delivery is selected for this system because:
- API key operations are idempotent - duplicate events won't cause data corruption
- The system can handle duplicate notifications gracefully (e.g., key already rotated)
- Simpler implementation and better performance characteristics
- Most downstream consumers can tolerate duplicate events
Retry policy for consumers
public class RetryPolicy {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_DELAY_MS = 5000;
private static final double BACKOFF_MULTIPLIER = 2.0;
private static final long MAX_DELAY_MS = 60000;
public boolean shouldRetry(int attempt, Exception exception) {
if (attempt >= MAX_RETRIES) return false;
// Don't retry on certain fatal errors
if (exception instanceof ValidationException ||
exception instanceof UnauthorizedAccessException) {
return false;
}
return true;
}
public long getDelay(int attempt) {
long delay = (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt));
return Math.min(delay, MAX_DELAY_MS);
}
}
Outbox pattern for reliable event publishing
Outbox pattern is implemented to ensure eventual consistency between database writes and message publishing:
- When an API key is created or modified, the operation is first persisted to a local outbox table
- A background worker polls the outbox table and publishes messages to the message broker
- Messages are marked as published once successfully delivered
- Failed messages are retried according to the retry policy
This prevents message loss when database transactions fail and ensures that all key state changes are eventually communicated to downstream services.
Sequence diagram for async flows
sequenceDiagram
participant Client
participant APIKeyService
participant DB
participant Outbox
participant MessageBroker
participant Consumer
Client->>APIKeyService: Create API Key
APIKeyService->>DB: Save Key Record
APIKeyService->>Outbox: Insert Event to Outbox
APIKeyService-->>Client: Return Key ID
loop Poll Outbox
Outbox->>MessageBroker: Publish Event
MessageBroker-->>Consumer: Deliver Event
end
Consumer->>DB: Update Key Usage Metrics
Consumer->>AuditService: Log Key Event
10. Error Handling Design
Error Classification Hierarchy
The API Key Management system follows a three-tier error classification:
- Domain Errors - Business logic violations (e.g., invalid key format, exceeded rate limits)
- Infrastructure Errors - System-level issues (e.g., database connection failure, cache unavailability)
- Validation Errors - Input validation failures (e.g., missing required fields, malformed JSON)
public class ApiKeyManagementException extends RuntimeException {
private final ErrorCode errorCode;
private final ErrorCategory category;
public ApiKeyManagementException(ErrorCode errorCode, ErrorCategory category, String message) {
super(message);
this.errorCode = errorCode;
this.category = category;
}
}
public enum ErrorCategory {
DOMAIN, INFRASTRUCTURE, VALIDATION
}
public enum ErrorCode {
INVALID_API_KEY_FORMAT,
API_KEY_NOT_FOUND,
EXCEEDED_RATE_LIMITS,
DATABASE_CONNECTION_FAILURE,
CACHE_UNAVAILABLE,
INVALID_INPUT,
INTERNAL_SERVER_ERROR
}
Error Propagation Strategy
Errors are propagated up the call stack with appropriate wrapping to preserve context:
// Example error propagation in service layer
public class ApiKeyService {
public ApiKey createApiKey(CreateApiKeyRequest request) {
try {
validateRequest(request);
return apiKeyRepository.save(buildApiKey(request));
} catch (DataAccessException e) {
throw new ApiKeyManagementException(
ErrorCode.DATABASE_CONNECTION_FAILURE,
ErrorCategory.INFRASTRUCTURE,
"Failed to save API key",
e
);
} catch (ValidationException e) {
throw new ApiKeyManagementException(
ErrorCode.INVALID_INPUT,
ErrorCategory.VALIDATION,
"Invalid API key request",
e
);
}
}
}
Retry Strategy with Exponential Backoff and Jitter
For transient infrastructure errors, a retry mechanism is implemented:
public class RetryableOperation {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public <T> T executeWithRetry(Supplier<T> operation) {
Exception lastException = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return operation.get();
} catch (ApiKeyManagementException e) {
if (attempt == MAX_RETRIES || !isRetryableError(e)) {
throw e;
}
lastException = e;
}
if (attempt < MAX_RETRIES) {
long delay = calculateBackoffDelay(attempt);
Thread.sleep(delay);
}
}
throw new ApiKeyManagementException(
ErrorCode.INTERNAL_SERVER_ERROR,
ErrorCategory.INFRASTRUCTURE,
"Operation failed after retries",
lastException
);
}
private long calculateBackoffDelay(int attempt) {
long baseDelay = (long) (BASE_DELAY_MS * Math.pow(2, attempt));
long jitter = (long) (baseDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return baseDelay + jitter;
}
private boolean isRetryableError(ApiKeyManagementException e) {
return e.getErrorCode() == ErrorCode.DATABASE_CONNECTION_FAILURE ||
e.getErrorCode() == ErrorCode.CACHE_UNAVAILABLE;
}
}
Circuit Breaker Design
The system implements a circuit breaker pattern for external dependencies:
public class CircuitBreaker {
private static final int FAILURE_THRESHOLD = 5;
private static final long TIMEOUT_MS = 60000;
private static final int RESET_TIMEOUT_MS = 30000;
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
public enum CircuitState {
CLOSED, OPEN, HALF_OPEN
}
public <T> T execute(Supplier<T> operation) throws Exception {
switch (state) {
case CLOSED:
return executeClosed(operation);
case OPEN:
return executeOpen(operation);
case HALF_OPEN:
return executeHalfOpen(operation);
default:
throw new IllegalStateException("Unknown circuit state");
}
}
private <T> T executeClosed(Supplier<T> operation) throws Exception {
try {
T result = operation.get();
failureCount = 0; // Reset on success
return result;
} catch (Exception e) {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= FAILURE_THRESHOLD) {
state = CircuitState.OPEN;
}
throw e;
}
}
private <T> T executeOpen(Supplier<T> operation) throws Exception {
if (System.currentTimeMillis() - lastFailureTime > RESET_TIMEOUT_MS) {
state = CircuitState.HALF_OPEN;
throw new Exception("Circuit breaker in half-open state");
}
throw new Exception("Circuit breaker is open, rejecting request");
}
private <T> T executeHalfOpen(Supplier<T> operation) throws Exception {
try {
T result = operation.get();
state = CircuitState.CLOSED;
return result;
} catch (Exception e) {
state = CircuitState.OPEN;
throw e;
}
}
}
Fallback Behavior Design
Fallbacks are implemented for critical operations:
public class ApiKeyService {
private final CircuitBreaker circuitBreaker = new CircuitBreaker();
public ApiKey getApiKey(String keyId) {
return circuitBreaker.execute(() -> {
try {
return apiKeyRepository.findById(keyId);
} catch (Exception e) {
// Fallback to cache or default behavior
return fallbackApiKey(keyId);
}
});
}
private ApiKey fallbackApiKey(String keyId) {
// Log the fallback for monitoring
logger.warn("Falling back to cached or default API key for ID: {}", keyId);
// Return a default or cached version if available
return new ApiKey(keyId, "fallback", "default");
}
}
Error Logging Format and Alerting Thresholds
Structured logging is implemented with consistent error fields:
public class ErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
public void logError(String operation, Exception error, Map<String, Object> context) {
Map<String, Object> logFields = new HashMap<>();
logFields.put("timestamp", Instant.now().toString());
logFields.put("operation", operation);
logFields.put("error_code", extractErrorCode(error));
logFields.put("error_message", error.getMessage());
logFields.put("stack_trace", error.getStackTrace());
logFields.putAll(context);
logger.error("API Key Management Error: {}", logFields);
}
private String extractErrorCode(Exception error) {
if (error instanceof ApiKeyManagementException) {
return ((ApiKeyManagementException) error).getErrorCode().name();
}
return "UNKNOWN_ERROR";
}
}
Alerting thresholds are configured for critical errors:
| Error Type | Alert Threshold | Severity |
|---|---|---|
| DATABASE_CONNECTION_FAILURE | 5+ occurrences in 5 minutes | CRITICAL |
| CACHE_UNAVAILABLE | 3+ occurrences in 1 minute | MAJOR |
| INVALID_INPUT | 10+ occurrences in 5 minutes | MINOR |
| INTERNAL_SERVER_ERROR | 3+ occurrences in 1 minute | CRITICAL |
Error Response Format
Standardized JSON error responses are returned to clients:
{
"error": {
"code": "DATABASE_CONNECTION_FAILURE",
"message": "Failed to connect to database",
"timestamp": "2023-10-15T14:30:00Z",
"request_id": "req-12345"
}
}
Error Handling in HTTP Layer
HTTP error responses are standardized:
@RestControllerAdvice
public class ApiKeyExceptionHandler {
@ExceptionHandler(ApiKeyManagementException.class)
public ResponseEntity<ErrorResponse> handleApiKeyError(
ApiKeyManagementException ex, WebRequest request) {
ErrorResponse errorResponse = new ErrorResponse(
ex.getErrorCode().name(),
ex.getMessage(),
Instant.now().toString(),
getRequestId(request)
);
HttpStatus status = mapErrorCodeToHttpStatus(ex.getErrorCode());
return ResponseEntity.status(status).body(errorResponse);
}
private HttpStatus mapErrorCodeToHttpStatus(ErrorCode code) {
switch (code) {
case INVALID_API_KEY_FORMAT:
case INVALID_INPUT:
return HttpStatus.BAD_REQUEST;
case API_KEY_NOT_FOUND:
return HttpStatus.NOT_FOUND;
case EXCEEDED_RATE_LIMITS:
return HttpStatus.TOO_MANY_REQUESTS;
default:
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
}
11. Security Design
Input validation and sanitization strategy
All API inputs are validated using a combination of:
- Custom validation annotations for business logic (e.g.,
@ValidApiKey,@ValidPermission) - Apache Commons Validator for standard validations (email, URL, UUID)
- OWASP Java HTML Sanitizer for any user-provided content that may be rendered in UIs
- Regex-based validation for API key format, naming conventions, and metadata fields
public class ApiKeyRequest {
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9_-]{1,64}$")
private String name;
@ValidPermissions
private List<String> permissions;
@FutureOrPresent
private LocalDateTime expiration;
}
SQL injection prevention
All database queries are constructed using:
- Prepared statements with parameterized queries (JDBC/MyBatis)
- ORM mapping with strict type checking (Hibernate)
- Whitelisting of column names and table names in dynamic queries
- No string concatenation for query construction
// Instead of:
String sql = "SELECT * FROM api_keys WHERE name = '" + name + "'";
// Use:
String sql = "SELECT * FROM api_keys WHERE name = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, name);
Authentication middleware design
Authentication is handled via a centralized filter that:
- Validates JWT tokens using RS256 algorithm
- Checks token expiration and signature
- Extracts user context from claims (user_id, roles, tenant_id)
- Sets security context in thread-local for downstream services
@Component
public class JwtAuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String token = extractTokenFromHeader(request);
if (token != null && validateToken(token)) {
Authentication auth = createAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response);
}
}
Authorization enforcement
RBAC (Role-Based Access Control) is implemented with:
- Fine-grained permissions stored in database per API key
- Permission checking at service layer using
@PreAuthorizeannotations - Context-aware authorization that considers user, tenant, and resource scope
@PreAuthorize("@authorizationService.canManageApiKey(principal, #apiKeyId)")
public void deleteApiKey(String apiKeyId) {
// Implementation
}
Sensitive data encryption at rest and in transit
At Rest:
- AES-256-GCM encryption for API key secrets using AWS KMS
- Key rotation every 90 days with automatic re-encryption of existing keys
In Transit:
- TLS 1.3 enforced for all API endpoints
- HSTS headers with 31536000 max-age and includeSubDomains
- Certificate pinning for mobile clients
PII handling and masking strategy
All PII data is:
- Masked in logs using a consistent pattern (e.g., "API_KEY_XXXXXX")
- Redacted in responses when returning sensitive fields
- Stored encrypted with proper key management (AWS KMS)
- Compliant with GDPR/CCPA - data retention policies enforced
public class ApiKeyResponse {
@JsonIgnore
private String secret;
public String getSecret() {
return "API_KEY_" + secret.substring(secret.length() - 6);
}
}
Secrets management
- AWS Secrets Manager for storing API key secrets and rotation policies
- Automatic rotation every 90 days with 30-day grace period
- Least privilege access - only services that need secrets can retrieve them
- Audit logging of all secret access operations
Audit logging design
All security-relevant events are logged with:
- Structured JSON format including trace_id, user_id, timestamp, action, resource_id
- Comprehensive event types: key creation, deletion, access, permission changes, rotation
- Retention policy: 365 days for audit logs, 90 days for operational logs
- Alerting thresholds: 10 failed authentication attempts within 5 minutes triggers alert
{
"timestamp": "2023-10-15T14:30:45.123Z",
"trace_id": "abc123def456",
"user_id": "user-123",
"action": "API_KEY_CREATED",
"resource_id": "key-456",
"ip_address": "192.168.1.100",
"permissions": ["read", "write"]
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage with focus on business logic, validation, and error handling paths
- Repository Layer: 90% coverage with emphasis on query correctness and edge case handling
- Domain Objects: 100% coverage for value objects and immutable entities
- Exception Handling: 100% coverage for all custom exception paths and fallbacks
- Security Logic: 95% coverage for authentication, authorization, and input validation logic
Integration Testing Strategy
- Database Integration: Use Testcontainers with PostgreSQL 13+ for end-to-end database tests
- External Service Mocking: WireMock for API key validation service integrations and external auth providers
- Event System Testing: Embedded Kafka or RabbitMQ for async flow validation using embedded message brokers
- Cache Integration: Redis Testcontainers to validate cache behavior and invalidation logic
- Authentication Endpoints: Use embedded OAuth2 server (e.g., Spring Security Test) to validate JWT handling and token validation flows
Contract Testing
- Pact Broker Integration: Use Pact for consumer-driven contract testing between API key service and dependent services (e.g., auth gateway, billing system)
- Versioned Contracts: Maintain contract versions per API version to prevent breaking changes in dependent systems
- Consumer Verification: Run contract tests as part of CI pipeline to ensure API key service adheres to defined contracts
Performance Testing Tool and Key Scenarios
- Tool: k6 (JavaScript-based load testing) for high-concurrency scenarios and latency validation
- Key Scenarios:
- Concurrent API key creation (1000 simultaneous requests)
- High-volume token validation (5000/sec validation rate)
- Cache warm-up and hit ratio validation under load
- Database read/write contention under burst traffic
- Metrics Tracked:
- p50/p95/p99 response times (target: <20ms for validation, <50ms for creation)
- Throughput (target: 10k req/sec for validation endpoint)
- Error rates (<0.1% failure rate expected)
Test Data Management Strategy
- Fixtures: Use JSON/YAML fixtures for predefined test data (e.g., user roles, client applications)
- Factories: Implement Factory pattern for generating test entities (API keys, users, apps) with configurable attributes
- Seeding: Use database migration scripts for initial test data in Testcontainers, with clean state reset between test runs
- Data Isolation: Each test suite runs in isolated database schema or container to prevent cross-test pollution
- Test Data Generation: Use libraries like Faker for generating realistic but non-sensitive test data (e.g., fake client names, mock API key values)
// Example unit test for API key validation service
@Test
public void testValidateApiKey_InvalidKey_ReturnsFalse() {
// Given
String invalidKey = "invalid-key-format";
// When
boolean isValid = apiKeyService.validateApiKey(invalidKey);
// Then
assertFalse(isValid);
}
@Test
public void testValidateApiKey_ValidKey_ReturnsTrue() {
// Given
String validKey = "sk_live_abc123xyz";
when(apiKeyRepository.findByKey(validKey)).thenReturn(Optional.of(createValidApiKey()));
// When
boolean isValid = apiKeyService.validateApiKey(validKey);
// Then
assertTrue(isValid);
}
13. Observability Design
Structured Logging Schema
All logs must follow a structured format with consistent fields to enable effective search, filtering, and analysis. The schema includes:
{
"timestamp": "2023-10-27T14:30:00.000Z",
"level": "INFO|WARN|ERROR",
"service": "api-key-mgmt-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user-123",
"entity_id": "api-key-456",
"operation": "generateApiKey",
"request_id": "req-789",
"message": "API key generated successfully",
"error_code": "API_KEY_GENERATION_FAILED",
"error_details": {
"type": "ValidationException",
"message": "API key name must be between 3 and 50 characters"
},
"duration_ms": 125,
"tags": ["audit", "security"]
}
PII masking rules:
- User IDs and API key values are masked in logs
- Only the first 3 and last 3 characters of sensitive fields are retained (e.g., "api-key-1234567890" → "api-key-123...7890")
Distributed Tracing Instrumentation
Using OpenTelemetry, we instrument key operations with custom spans:
-
API Key Generation Flow:
- Span:
generate_api_key - Attributes:
user_id,api_key_name,key_type,request_id
- Span:
-
API Key Validation Flow:
- Span:
validate_api_key - Attributes:
api_key_id,user_id,validation_result
- Span:
-
API Key Rotation Flow:
- Span:
rotate_api_key - Attributes:
old_key_id,new_key_id,user_id
- Span:
-
API Key Revocation Flow:
- Span:
revoke_api_key - Attributes:
api_key_id,user_id,revoked_at
- Span:
Metrics Design
Service-Level RED Metrics
| Metric Name | Type | Labels | Description |
|---|---|---|---|
api_key_requests_total | Counter | method, endpoint, status_code | Total API key operations |
api_key_request_duration_seconds | Histogram | method, endpoint | Request processing time |
api_key_errors_total | Counter | error_type, endpoint | Failed API key operations |
api_key_active_count | Gauge | key_type, status | Currently active keys |
api_key_usage_count | Histogram | key_id, user_id | API key usage frequency |
Business Metrics
| Metric Name | Type | Labels | Description |
|---|---|---|---|
api_key_creation_rate | Counter | user_id, key_type | Number of keys created per user |
api_key_rotation_rate | Counter | user_id, key_type | Rotation frequency |
api_key_deactivation_rate | Counter | user_id, reason | Key deactivation reasons |
Health Check Endpoint Design
GET /health
GET /ready
GET /live
GET /startup
Response Format:
{
"status": "healthy|unhealthy",
"checks": {
"database": {
"status": "healthy",
"latency_ms": 15
},
"cache": {
"status": "healthy",
"latency_ms": 5
},
"auth_service": {
"status": "healthy",
"latency_ms": 20
}
},
"timestamp": "2023-10-27T14:30:00.000Z"
}
Alerting Thresholds
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
api_key_request_duration_seconds | 500ms (p99) | 2s (p99) | On-call engineer |
api_key_errors_total | 10 errors/minute | 50 errors/minute | On-call engineer + SRE |
api_key_requests_total | 100k requests/minute | 500k requests/minute | SRE team |
database_connection_pool_utilization | 80% | 95% | SRE team |
cache_hit_rate | <80% | <60% | SRE team |
api_key_active_count | >1M keys | >2M keys | SRE team + Product |
Log Rotation and Retention
- Log Rotation: Rotate logs daily or when file size exceeds 100MB
- Retention Policy:
- 30 days for INFO level logs
- 7 days for ERROR level logs
- 1 day for DEBUG level logs
Audit Logging Design
All security-sensitive operations are logged with full audit trail:
{
"timestamp": "2023-10-27T14:30:00.000Z",
"operation": "API_KEY_GENERATED",
"user_id": "user-123",
"api_key_id": "key-456",
"details": {
"name": "Production API Key",
"type": "production",
"created_by": "admin@company.com",
"permissions": ["read", "write"],
"created_at": "2023-10-27T14:30:00.000Z"
},
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0..."
}
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Key rotation policy: Should keys be rotated automatically or only on explicit request? What's the acceptable window for key rotation without breaking existing integrations?
- Key lifecycle management: Should API keys be automatically disabled after a certain period of inactivity? What's the grace period before complete deletion?
- Rate limiting strategy: Should rate limits be applied at key level or account level? How granular should the rate limiting be (per endpoint vs. global)?
- Key revocation mechanism: Should keys be revoked immediately or marked for soft deletion with a grace period?
Known trade-offs and justifications
- Storage vs. Performance Trade-off: Storing keys in plaintext in database for fast lookup vs. storing only hashed values for security. We chose to store only hashes with plaintext keys in memory cache for performance, but this introduces risk of key exposure in memory.
- Consistency vs. Availability Trade-off: Using strong consistency for key state changes vs. eventual consistency. We chose strong consistency for critical operations like key creation/deletion but allow eventual consistency for read-heavy operations.
- Security vs. Usability Trade-off: Complex key generation vs. simple, user-friendly keys. We opted for UUID-based keys that are secure but also easy to copy/paste.
- Auditability vs. Performance Trade-off: Comprehensive audit logging vs. minimal overhead. We implement audit logging with async writes to avoid blocking primary operations.
Risks and mitigation strategies
- Key Exfiltration Risk: If keys are compromised, they can be used indefinitely until revoked. Mitigation: Implement key rotation and monitoring for unusual usage patterns.
- Denial of Service Risk: If the system becomes unavailable, all API integrations break. Mitigation: Implement circuit breaker patterns and fallback to cached key validation.
- Data Loss Risk: If the database storing keys fails, all keys become inaccessible. Mitigation: Use replication and backup strategies with cross-region redundancy.
- Cache Invalidation Risk: If cache becomes stale, systems might accept invalid keys. Mitigation: Implement TTL with proactive cache warming and event-driven invalidation.
Technical debt acknowledged
- Legacy Key Format Support: The system currently supports both old and new key formats, but this creates maintenance overhead and potential security gaps. This will be deprecated in Q2 2025.
- Hardcoded Rate Limit Values: Rate limits are currently hardcoded in configuration files rather than being dynamically configurable. This creates operational overhead for tuning.
- Monolithic Key Storage: All key data is stored in a single database table without proper sharding strategy. This creates a potential scalability bottleneck as key count grows.
- Inconsistent Error Handling: Some error responses are not standardized across different service methods, creating inconsistency in client-side handling.
- No Key Metadata Versioning: Key metadata changes are not versioned, which makes it difficult to support backward compatibility when key properties change.