This document presents a production-ready low-level design for an authentication service that handles user registration, login, token management, and secure session handling with support for multi-factor authentication and role-based access control.
1. Problem Statement & Requirements
Clarifying questions
-
What scale expectations are we targeting?
- How many concurrent users/requests per second?
- What is the expected peak load (e.g., 10K, 100K, 1M+ concurrent sessions)?
-
What are the consistency and availability requirements?
- Should authentication be strongly consistent (e.g., for session invalidation) or eventually consistent?
- What is the target availability SLA (e.g., 99.9% uptime)?
-
What are the key entities and their relationships?
- Are we dealing with users, roles, permissions, or just basic login sessions?
- Do we need to support multi-tenancy or cross-service session sharing?
-
What is the read/write ratio for auth operations?
- Is login/logout more frequent than session refresh or token validation?
- Are we expecting high burst traffic during peak hours?
-
How sensitive is the authentication flow to latency?
- Is sub-100ms response time critical for login, or can we tolerate longer delays?
- Are there specific user experience constraints (e.g., mobile app login flow)?
-
What authentication methods do we support?
- OAuth2, SAML, JWT, or basic username/password?
- Do we need to integrate with external identity providers (e.g., Google, GitHub)?
-
What external systems or services does this auth service integrate with?
- Does it need to communicate with user profile services, audit logs, or session stores?
Functional requirements
- User Authentication: Support login via username/password or OAuth2 providers
- Session Management: Create, validate, and invalidate user sessions
- Token Generation: Issue JWTs for API access or session tokens
- User Registration: Allow new user creation with password hashing and validation
- Password Reset: Support password recovery via email or SMS
- Role-Based Access Control (RBAC): Enforce access control based on user roles and permissions
- Multi-Factor Authentication (MFA): Support for MFA tokens or biometric verification
- Audit Logging: Track authentication events and session activities
Non-functional requirements
| Requirement | Target |
|---|---|
| P50 Latency | < 50ms for token validation |
| P95 Latency | < 100ms for login flow |
| P99 Latency | < 200ms for token refresh |
| Throughput | 10,000+ concurrent auth requests/sec |
| Availability | 99.9% uptime (5min max downtime/year) |
| Scalability | Horizontally scalable to support 1M+ users |
| Security | FIPS compliant encryption, secure token handling |
Constraints and assumptions
- Assumption: Authentication service will be a standalone microservice
- Constraint: Must support JWT-based token validation without database lookups for performance
- Assumption: Sessions are stateless and managed via tokens (no server-side session storage)
- Constraint: All sensitive data must be encrypted at rest and in transit
- Assumption: Users will authenticate via web, mobile, or API clients
Out of scope
- User Profile Management: Full user profile CRUD operations (handled by a separate service)
- Social Login Integration: OAuth2 provider integration (e.g., Google, GitHub) is out of scope for this design
- Password Policy Enforcement: Complex password rules (e.g., special character requirements) are not part of this design
- Device Fingerprinting: Tracking devices or IP-based risk assessment is not included
- Compliance Auditing: Detailed audit logs with full compliance reporting (e.g., SOC2, GDPR) are not in scope
- Cross-service Session Sharing: Session sharing between microservices is not part of this design
2. API Design
API Style Selection
For an authentication service, I'll select RESTful APIs with JSON payloads for the following reasons:
- Familiarity: REST is widely understood by developers and tooling
- Statelessness: Authentication services are inherently stateless, fitting REST's stateless nature
- Caching: REST allows for easy caching strategies at CDN or client level
- Tooling: Rich ecosystem of REST clients, testing tools, and monitoring solutions
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /v1/auth/login | Authenticate user and return session token | None |
| POST | /v1/auth/logout | Invalidate current session | Bearer Token |
| GET | /v1/auth/me | Get authenticated user details | Bearer Token |
| POST | /v1/auth/register | Register new user account | None |
| PUT | /v1/auth/password | Update password for authenticated user | Bearer Token |
| POST | /v1/auth/forgot-password | Initiate password reset process | None |
| POST | /v1/auth/reset-password | Complete password reset with token | None |
| GET | /v1/auth/validate-token | Validate session token | Bearer Token |
| DELETE | /v1/auth/account | Delete user account | Bearer Token |
Request and Response Schemas
Login Request
{
"email": "user@example.com",
"password": "securePassword123"
}
Login Response
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"token_type": "Bearer"
}
Register Request
{
"email": "newuser@example.com",
"password": "securePassword123",
"first_name": "John",
"last_name": "Doe"
}
Register Response
{
"user_id": "uuid-12345",
"email": "newuser@example.com",
"first_name": "John",
"last_name": "Doe"
}
Forgot Password Request
{
"email": "user@example.com"
}
Forgot Password Response
{
"message": "Password reset instructions sent if account exists"
}
Reset Password Request
{
"reset_token": "abc123xyz",
"new_password": "NewSecurePassword456"
}
Reset Password Response
{
"message": "Password successfully reset"
}
HTTP Status Codes and Standardized Error Response
| Status Code | Description |
|---|---|
| 200 | Success - GET, PUT, DELETE |
| 201 | Created - POST |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Invalid or missing auth |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found - Resource not found |
| 429 | Too Many Requests - Rate limited |
| 500 | Internal Server Error - Unexpected server error |
Standardized error response format:
{
"error": {
"code": "INVALID_INPUT",
"message": "Password must be at least 8 characters long",
"details": {
"field": "password",
"value": "short"
}
}
}
API Versioning Strategy
Versioning will be done via path prefix: /v1/ for the first stable version. This approach:
- Is explicit and easy to understand
- Allows for parallel support of multiple versions
- Follows REST conventions
- Enables easy migration path
Pagination Design
For authentication service, pagination is not required as:
- User profiles and session lists are typically small
- Token operations don't involve large collections
- The service is primarily stateless and focused on authentication
Authentication and Authorization
Authentication: JWT tokens with Bearer scheme for all authenticated endpoints Authorization:
- All endpoints requiring authentication use JWT Bearer tokens
- Session validation is performed via token introspection or local verification
- Role-based access control (RBAC) will be implemented for admin endpoints
Rate Limiting Strategy
| Endpoint | Limit | Period |
|---|---|---|
/login | 5 attempts | 1 minute |
/register | 3 attempts | 1 hour |
/forgot-password | 3 attempts | 1 hour |
/reset-password | 5 attempts | 1 minute |
Rate limiting will be implemented using:
- In-memory or Redis-based counters
- Token bucket algorithm for smooth rate limiting
- Distributed implementation using Redis to support multi-instance deployments
Idempotency Design
For mutation endpoints, idempotency keys will be supported:
Request Header: Idempotency-Key: uuid-12345
Implementation Strategy:
- Store idempotency keys in Redis with TTL of 24 hours
- For POST /register and PUT /password, if same key exists with success status, return cached result
- For login/logout, idempotency is less critical but can be implemented for consistency
// Example idempotency handling in service layer
public class IdempotencyService {
private final RedisTemplate<String, String> redisTemplate;
public ApiResponse handleIdempotentRequest(String key, Supplier<ApiResponse> operation) {
String cacheKey = "idempotency:" + key;
String cachedResult = redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return ApiResponse.fromJson(cachedResult);
}
ApiResponse result = operation.get();
redisTemplate.opsForValue().set(cacheKey, result.toJson(), 24, TimeUnit.HOURS);
return result;
}
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
USER {
string id PK
string email
string hashed_password
string salt
boolean is_verified
boolean is_active
timestamp created_at
timestamp updated_at
timestamp last_login_at
}
USER_SESSION {
string id PK
string user_id FK
string session_token
string refresh_token
timestamp expires_at
timestamp refresh_expires_at
string user_agent
string ip_address
boolean is_active
timestamp created_at
timestamp updated_at
}
USER_DEVICE {
string id PK
string user_id FK
string device_fingerprint
string device_name
string device_type
boolean is_trusted
timestamp created_at
timestamp updated_at
}
USER_ROLE {
string id PK
string user_id FK
string role_name
timestamp created_at
}
ROLE_PERMISSION {
string id PK
string role_name
string permission_name
timestamp created_at
}
USER_OTP {
string id PK
string user_id FK
string otp_hash
string otp_type
timestamp expires_at
boolean is_used
timestamp created_at
}
USER_LOGIN_ATTEMPT {
string id PK
string user_id FK
string ip_address
string user_agent
boolean is_successful
timestamp created_at
}
USER {
string id PK
string email
string hashed_password
string salt
boolean is_verified
boolean is_active
timestamp created_at
timestamp updated_at
timestamp last_login_at
}
USER_SESSION {
string id PK
string user_id FK
string session_token
string refresh_token
timestamp expires_at
timestamp refresh_expires_at
string user_agent
string ip_address
boolean is_active
timestamp created_at
timestamp updated_at
}
USER_DEVICE {
string id PK
string user_id FK
string device_fingerprint
string device_name
string device_type
boolean is_trusted
timestamp created_at
timestamp updated_at
}
USER_ROLE {
string id PK
string user_id FK
string role_name
timestamp created_at
}
ROLE_PERMISSION {
string id PK
string role_name
string permission_name
timestamp created_at
}
USER_OTP {
string id PK
string user_id FK
string otp_hash
string otp_type
timestamp expires_at
boolean is_used
timestamp created_at
}
USER_LOGIN_ATTEMPT {
string id PK
string user_id FK
string ip_address
string user_agent
boolean is_successful
timestamp created_at
}
USER ||--o{ USER_SESSION : has
USER ||--o{ USER_DEVICE : has
USER ||--o{ USER_ROLE : has
USER ||--o{ USER_OTP : has
USER ||--o{ USER_LOGIN_ATTEMPT : has
USER_ROLE ||--o{ ROLE_PERMISSION : has
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,
salt TEXT NOT NULL,
is_verified BOOLEAN DEFAULT FALSE,
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_created_at (created_at)
);
-- User sessions table
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
session_token TEXT UNIQUE NOT NULL,
refresh_token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
refresh_expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
user_agent TEXT,
ip_address INET,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_sessions_user_id (user_id),
INDEX idx_user_sessions_session_token (session_token),
INDEX idx_user_sessions_refresh_token (refresh_token),
INDEX idx_user_sessions_expires_at (expires_at)
);
-- User devices table
CREATE TABLE user_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
device_fingerprint TEXT UNIQUE NOT NULL,
device_name TEXT,
device_type TEXT,
is_trusted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_devices_user_id (user_id),
INDEX idx_user_devices_fingerprint (device_fingerprint)
);
-- User roles table
CREATE TABLE user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_roles_user_id (user_id),
INDEX idx_user_roles_role_name (role_name)
);
-- Role permissions table
CREATE TABLE role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role_name VARCHAR(100) NOT NULL,
permission_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_role_permissions_role_name (role_name),
INDEX idx_role_permissions_permission_name (permission_name)
);
-- User OTP table
CREATE TABLE user_otps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
otp_hash TEXT NOT NULL,
otp_type VARCHAR(50) NOT NULL, -- 'TOTP', 'EMAIL', 'SMS'
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_otps_user_id (user_id),
INDEX idx_user_otps_expires_at (expires_at)
);
-- User login attempts table
CREATE TABLE user_login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
ip_address INET,
user_agent TEXT,
is_successful BOOLEAN NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_login_attempts_user_id (user_id),
INDEX idx_user_login_attempts_created_at (created_at)
);
Primary Key Strategy
UUID as Primary Key:
- Justification: UUIDs provide globally unique identifiers without requiring coordination between services or databases.
- Advantages:
- No need for centralized ID generation
- Support for distributed systems and horizontal scaling
- Better security through obscurity (no sequential patterns)
- Disadvantages:
- Larger storage footprint (16 bytes vs 8 bytes for BIGINT)
- Potential performance impact on indexing due to random distribution
Index Design
| Index Name | Table | Columns | Type | Justification |
|---|---|---|---|---|
| idx_users_email | users | Unique | Fast email lookups for authentication | |
| idx_users_created_at | users | created_at | Standard | Support for user activity analytics |
| idx_user_sessions_user_id | user_sessions | user_id | Standard | Fast session retrieval by user |
| idx_user_sessions_session_token | user_sessions | session_token | Unique | Fast session validation |
| idx_user_sessions_refresh_token | user_sessions | refresh_token | Unique | Fast refresh token validation |
| idx_user_sessions_expires_at | user_sessions | expires_at | Standard | Efficient session cleanup |
| idx_user_devices_user_id | user_devices | user_id | Standard | Fast device lookup by user |
| idx_user_devices_fingerprint | user_devices | device_fingerprint | Unique | Fast device matching |
| idx_user_roles_user_id | user_roles | user_id | Standard | Fast role lookup by user |
| idx_user_roles_role_name | user_roles | role_name | Standard | Fast role-based permission checks |
| idx_role_permissions_role_name | role_permissions | role_name | Standard | Fast permission lookup by role |
| idx_user_otps_user_id | user_otps | user_id | Standard | Fast OTP lookup by user |
| idx_user_otps_expires_at | user_otps | expires_at | Standard | Efficient OTP cleanup |
| idx_user_login_attempts_user_id | user_login_attempts | user_id | Standard | Fast login attempt analysis |
| idx_user_login_attempts_created_at | user_login_attempts | created_at | Standard | Audit trail and rate limiting |
Data Partitioning / Sharding Strategy
Sharding by User ID (UUID):
- Strategy: Hash-based sharding on the first 4 bytes of UUID to distribute users across shards
- Rationale:
- Even distribution of data and load
- Simple implementation with minimal complexity
- Maintains referential integrity within each shard
- Considerations:
- Cross-shard queries require additional coordination (e.g., user session refresh across shards)
- Requires careful handling of cross-shard operations in distributed systems
Soft Delete vs. Hard Delete Decision
Soft Delete with is_active flag:
- Justification:
- Support for data recovery in case of accidental deletion
- Compliance with audit requirements (data retention policies)
- Ability to reactivate users without re-creating accounts
- Implementation:
- Set
is_active = FALSEinstead of deleting records - Add soft-delete triggers to maintain referential integrity
- Archive old sessions and devices after inactivity period
- Set
Audit Trail Design
Audit Fields:
created_at: Timestamp when record was createdupdated_at: Timestamp when record was last modifiedcreated_by,updated_by(if needed): User or system that made changes
Audit Events:
- User creation, login, password change
- Session creation and refresh
- Role assignment and removal
- Device trust status changes
Data Archival Strategy and Retention Policy
Archival Strategy:
- Inactive Users: Move to archive after 12 months of inactivity
- Login Attempts: Retain for 90 days for security analysis
- Session Data: Retain for 30 days or until session expiration
- Device Records: Retain for 6 months or until device is untrusted
Retention Policy:
- Compliance: 7 years for audit and compliance purposes
- Security: 30 days for login attempts and OTPs
- Operational: 6 months for device records and sessions
// Example of soft-delete pattern in Java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String hashedPassword;
@Column(nullable = false)
private String salt;
@Column(defaultValue = "false")
private boolean verified = false;
@Column(defaultValue = "true")
private boolean active = true;
@CreationTimestamp
@Column(nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(nullable = false)
private Instant updatedAt;
// Soft delete - set active to false instead of deleting
public void deactivate() {
this.active = false;
}
public boolean isActive() {
return active;
}
}
4. Class Design & Service Layer
UML Class Diagram
classDiagram
class AuthService {
+authenticate(AuthRequest) AuthResponse
+register(UserRegistration) User
+refreshToken(RefreshTokenRequest) AuthResponse
+logout(LogoutRequest) void
+validateToken(TokenValidationRequest) TokenValidationResponse
}
class AuthRepository {
+saveUser(User) User
+findUserByEmail(String) Optional~User~
+findUserById(String) Optional~User~
+saveToken(Token) Token
+findTokenByValue(String) Optional~Token~
+deleteToken(String) void
+findActiveTokensByUserId(String) List~Token~
}
class TokenService {
+generateAccessToken(User) String
+generateRefreshToken(User) String
+validateToken(String) TokenValidationResult
+invalidateToken(String) void
}
class UserService {
+createUser(UserRegistration) User
+getUserById(String) User
+getUserByEmail(String) User
+updateUser(UserUpdateRequest) User
}
class SecurityService {
+hashPassword(String) String
+verifyPassword(String, String) boolean
+generateSecureRandomToken() String
}
class EventPublisher {
+publishEvent(AuthEvent) void
}
class AuthEvent {
+eventType String
+userId String
+timestamp Instant
}
class User {
-id String
-email String
-passwordHash String
-createdAt Instant
-updatedAt Instant
-isActive boolean
}
class Token {
-id String
-value String
-userId String
-type TokenType
-expiresAt Instant
-createdAt Instant
}
class AuthRequest {
+email String
+password String
}
class AuthResponse {
+accessToken String
+refreshToken String
+expiresIn Long
}
class RefreshTokenRequest {
+refreshToken String
}
class TokenValidationRequest {
+token String
}
class TokenValidationResponse {
+isValid boolean
+userId String
+expiresAt Instant
}
class UserRegistration {
+email String
+password String
+name String
}
AuthService --> AuthRepository : uses
AuthService --> TokenService : uses
AuthService --> UserService : uses
AuthService --> SecurityService : uses
AuthService --> EventPublisher : publishes
TokenService --> AuthRepository : uses
UserService --> AuthRepository : uses
SecurityService --> AuthRepository : uses
User <|-- AuthRequest : composition
User <|-- RefreshTokenRequest : composition
User <|-- TokenValidationRequest : composition
AuthEvent --> User : relates to
State Machine Diagram for Token
stateDiagram-v2
[*] --> Active
Active --> Expired : token expires
Active --> Invalidated : invalidated by logout or refresh
Expired --> [*]
Invalidated --> [*]
Service Class Structure and Responsibilities
The authentication service is composed of several key services:
- AuthService: Main entry point for all authentication operations
- UserService: Manages user lifecycle operations (create, update, fetch)
- TokenService: Handles token generation, validation, and lifecycle management
- SecurityService: Provides cryptographic operations (hashing, random generation)
- EventPublisher: Publishes authentication events for audit and monitoring
Interface / Abstract Class Design
public interface AuthRepository {
User saveUser(User user);
Optional<User> findUserByEmail(String email);
Optional<User> findUserById(String id);
Token saveToken(Token token);
Optional<Token> findTokenByValue(String tokenValue);
void deleteToken(String tokenValue);
List<Token> findActiveTokensByUserId(String userId);
}
public interface TokenService {
String generateAccessToken(User user);
String generateRefreshToken(User user);
TokenValidationResult validateToken(String token);
void invalidateToken(String tokenValue);
}
public interface SecurityService {
String hashPassword(String password);
boolean verifyPassword(String password, String hash);
String generateSecureRandomToken();
}
Business Logic Encapsulation Patterns
Strategy Pattern for Token Generation: Using a strategy pattern to allow different token generation approaches (JWT vs. opaque tokens) while maintaining a consistent interface.
public interface TokenGenerationStrategy {
String generateToken(User user, TokenType type);
}
public class JwtTokenStrategy implements TokenGenerationStrategy {
private final JwtService jwtService;
public JwtTokenStrategy(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
public String generateToken(User user, TokenType type) {
return jwtService.generateToken(user, type);
}
}
public class OpaqueTokenStrategy implements TokenGenerationStrategy {
private final SecurityService securityService;
public OpaqueTokenStrategy(SecurityService securityService) {
this.securityService = securityService;
}
@Override
public String generateToken(User user, TokenType type) {
return securityService.generateSecureRandomToken();
}
}
Critical-Path Method Implementation
@Transactional
public AuthResponse authenticate(AuthRequest request) {
// Validate input
if (request.getEmail() == null || request.getPassword() == null) {
throw new InvalidCredentialsException("Email and password are required");
}
// Find user by email
User user = userRepository.findUserByEmail(request.getEmail())
.orElseThrow(() -> new InvalidCredentialsException("Invalid credentials"));
// Verify password
if (!securityService.verifyPassword(request.getPassword(), user.getPasswordHash())) {
throw new InvalidCredentialsException("Invalid credentials");
}
// Check if user is active
if (!user.isActive()) {
throw new UserInactiveException("User account is inactive");
}
// Generate tokens
String accessToken = tokenService.generateAccessToken(user);
String refreshToken = tokenService.generateRefreshToken(user);
// Save refresh token
Token refresh = new Token();
refresh.setValue(refreshToken);
refresh.setUserId(user.getId());
refresh.setType(TokenType.REFRESH);
refresh.setExpiresAt(Instant.now().plus(Duration.ofDays(30)));
refresh.setCreatedAt(Instant.now());
tokenRepository.saveToken(refresh);
// Publish authentication event
AuthEvent event = new AuthEvent();
event.setEventType("USER_LOGIN");
event.setUserId(user.getId());
event.setTimestamp(Instant.now());
eventPublisher.publishEvent(event);
// Return response
AuthResponse response = new AuthResponse();
response.setAccessToken(accessToken);
response.setRefreshToken(refreshToken);
response.setExpiresIn(Duration.ofHours(1).getSeconds());
return response;
}
Transaction Boundary Design
- Single transaction scope: The
authenticateoperation is wrapped in a single database transaction to ensure atomicity - Consistent isolation level: READ_COMMITTED is used to prevent dirty reads and non-repeatable reads
- Transaction timeout: 30 seconds to prevent long-running transactions from blocking resources
Service Interface Contracts
public interface AuthService {
AuthResponse authenticate(AuthRequest request);
User register(UserRegistration registration);
AuthResponse refreshToken(RefreshTokenRequest request);
void logout(LogoutRequest request);
TokenValidationResponse validateToken(TokenValidationRequest request);
}
Saga / Orchestration Pattern
For complex operations like user registration with email verification, a saga pattern is used:
public class RegistrationSaga {
private final UserService userService;
private final EmailService emailService;
private final EventPublisher eventPublisher;
public void executeRegistration(UserRegistration registration) {
try {
// Step 1: Create user
User user = userService.createUser(registration);
// Step 2: Send verification email
emailService.sendVerificationEmail(user.getEmail());
// Step 3: Publish registration event
eventPublisher.publishEvent(new RegistrationEvent(user.getId()));
} catch (Exception e) {
// Compensating actions
handleRegistrationFailure(registration, e);
}
}
private void handleRegistrationFailure(UserRegistration registration, Exception error) {
// Rollback user creation
// Send failure notification
// Log error for monitoring
}
}
Domain Object / Value Object Design
public class User {
private String id;
private String email;
private String passwordHash;
private String name;
private Instant createdAt;
private Instant updatedAt;
private boolean isActive;
// Getters and setters
}
public enum TokenType {
ACCESS,
REFRESH
}
public class TokenValidationResult {
private boolean valid;
private String userId;
private Instant expiresAt;
private TokenType type;
// Getters and setters
}
public class AuthEvent {
private String eventType;
private String userId;
private Instant timestamp;
private Map<String, Object> metadata;
// Getters and setters
}
5. Concurrency & Thread Safety
Concurrency Model
The authentication service will use a thread pool model with a fixed-size thread pool for handling concurrent requests. Each HTTP request is handled by a separate thread from the application's thread pool (e.g., using Spring Boot's default Tomcat thread pool). For CPU-bound operations like password hashing, we'll use a dedicated executor service with a bounded thread pool to prevent resource exhaustion.
Race Condition Prevention Strategy
Race conditions are mitigated through:
- Atomic operations for counter updates (e.g., failed login attempts)
- Database-level constraints to prevent duplicate user creation
- Optimistic locking for session updates
- Thread-local storage for request-scoped context (e.g., current user ID, trace IDs)
- Synchronized access to shared in-memory caches
Optimistic vs. Pessimistic Locking
We use optimistic locking for session and token operations because:
- Authentication service is read-heavy with low write contention
- Session updates are infrequent (token refreshes, logout)
- Reduces lock wait times and improves throughput
- Token revocation is handled through soft-delete with TTL
Deadlock Prevention Strategy
Deadlock prevention includes:
- Consistent lock ordering - always acquire locks in the same order (e.g., session ID before user ID)
- Timeout-based acquisition - set 50ms timeouts for lock acquisition
- Avoid nested locks - minimize scope of synchronized blocks
- Use of concurrent collections (e.g., ConcurrentHashMap) where possible
Idempotency Implementation for Concurrent Duplicate Requests
For idempotent operations like token refresh and logout:
// Idempotency key handling in a concurrent environment
public class IdempotencyService {
private final Map<String, String> idempotencyCache = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public boolean isIdempotentRequest(String idempotencyKey, String operation) {
String existingOperation = idempotencyCache.putIfAbsent(idempotencyKey, operation);
if (existingOperation != null && !operation.equals(existingOperation)) {
throw new IdempotencyViolationException("Idempotency key already used for different operation");
}
// Schedule cleanup of idempotency key after TTL
scheduler.schedule(() -> idempotencyCache.remove(idempotencyKey),
IDEMPOTENCY_TTL_SECONDS, TimeUnit.SECONDS);
return existingOperation == null;
}
}
Thread Safety in Session Management
Session management uses thread-safe collections and atomic operations:
public class ThreadSafeSessionManager {
private final Map<String, Session> activeSessions = new ConcurrentHashMap<>();
private final AtomicLong sessionCounter = new AtomicLong(0);
public void addSession(String sessionId, Session session) {
activeSessions.put(sessionId, session);
}
public Session getSession(String sessionId) {
return activeSessions.get(sessionId);
}
public boolean removeSession(String sessionId) {
return activeSessions.remove(sessionId) != null;
}
public long getActiveSessionCount() {
return activeSessions.size();
}
}
Token Generation Thread Safety
Token generation uses synchronized methods for critical sections:
public class TokenGenerator {
private final SecureRandom secureRandom = new SecureRandom();
private final AtomicLong tokenCounter = new AtomicLong(0);
public synchronized String generateToken() {
// Use secure random for token generation
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
// Add timestamp and counter for uniqueness
long timestamp = System.currentTimeMillis();
long counter = tokenCounter.incrementAndGet();
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes) + "_" + timestamp + "_" + counter;
}
}
Concurrent User Authentication Flow
The authentication flow handles concurrent login attempts safely:
@Service
public class ConcurrentAuthenticationService {
private final Map<String, AtomicInteger> loginAttemptCount = new ConcurrentHashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public boolean isLoginAllowed(String username) {
// Check rate limit with read lock
lock.readLock().lock();
try {
AtomicInteger count = loginAttemptCount.get(username);
return count == null || count.get() < MAX_LOGIN_ATTEMPTS;
} finally {
lock.readLock().unlock();
}
}
public void recordFailedLogin(String username) {
// Update with write lock
lock.writeLock().lock();
try {
loginAttemptCount.computeIfAbsent(username, k -> new AtomicInteger(0))
.incrementAndGet();
} finally {
lock.writeLock().unlock();
}
}
public void resetLoginAttempts(String username) {
lock.writeLock().lock();
try {
loginAttemptCount.remove(username);
} finally {
lock.writeLock().unlock();
}
}
}
Thread Safety in Password Hashing
Password hashing operations are thread-safe but require proper resource management:
@Service
public class PasswordHasher {
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12);
// Thread-safe - BCryptPasswordEncoder is stateless
public String hashPassword(String rawPassword) {
return passwordEncoder.encode(rawPassword);
}
public boolean verifyPassword(String rawPassword, String hashedPassword) {
return passwordEncoder.matches(rawPassword, hashedPassword);
}
}
Session Cache Thread Safety
Session cache operations are protected with concurrent collections:
@Component
public class SessionCache {
private final Map<String, CachedSession> sessionCache = new ConcurrentHashMap<>();
public void put(String sessionId, CachedSession session) {
sessionCache.put(sessionId, session);
}
public CachedSession get(String sessionId) {
return sessionCache.get(sessionId);
}
public boolean remove(String sessionId) {
return sessionCache.remove(sessionId) != null;
}
public int size() {
return sessionCache.size();
}
}
Thread Safety in Token Refresh Operations
Token refresh operations use synchronized access to prevent race conditions:
@Service
public class TokenRefreshService {
private final Map<String, String> refreshTokens = new ConcurrentHashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public synchronized String refreshAccessToken(String refreshToken) {
// Validate and consume refresh token
String userId = validateAndConsumeRefreshToken(refreshToken);
// Generate new access token
String newAccessToken = generateNewAccessToken(userId);
// Update session with new tokens (atomic operation)
updateSessionTokens(userId, newAccessToken, refreshToken);
return newAccessToken;
}
private String validateAndConsumeRefreshToken(String refreshToken) {
// Thread-safe validation logic
lock.readLock().lock();
try {
String userId = refreshTokens.get(refreshToken);
if (userId != null) {
// Remove the used refresh token
refreshTokens.remove(refreshToken);
}
return userId;
} finally {
lock.readLock().unlock();
}
}
}
Thread Safety in Rate Limiting
Rate limiting is implemented with thread-safe atomic operations:
@Service
public class RateLimitingService {
private final Map<String, AtomicLong> requestCounters = new ConcurrentHashMap<>();
private final Map<String, Long> lastResetTimes = new ConcurrentHashMap<>();
public boolean isRequestAllowed(String key, int maxRequests, long windowSeconds) {
long now = System.currentTimeMillis();
AtomicLong counter = requestCounters.computeIfAbsent(key, k -> new AtomicLong(0));
Long lastReset = lastResetTimes.get(key);
// Reset counter if window has passed
if (lastReset == null || now - lastReset > windowSeconds * 1000) {
counter.set(0);
lastResetTimes.put(key, now);
}
// Thread-safe increment and check
return counter.incrementAndGet() <= maxRequests;
}
}
Thread Safety in Concurrent Session Validation
Session validation operations are thread-safe:
@Service
public class SessionValidator {
private final Map<String, Session> activeSessions = new ConcurrentHashMap<>();
public boolean validateSession(String sessionId) {
// Thread-safe session lookup
Session session = activeSessions.get(sessionId);
if (session == null) {
return false;
}
// Thread-safe session expiration check
return !session.isExpired();
}
public void invalidateSession(String sessionId) {
// Thread-safe session removal
activeSessions.remove(sessionId);
}
}
Concurrency Considerations for Multi-Node Deployment
In a multi-node deployment, we ensure thread safety through:
- Database-backed session storage to avoid in-memory inconsistency
- Distributed locks for critical operations (e.g., token generation)
- Consistent hashing for session distribution across nodes
- Cache coherency protocols to maintain consistency
Thread Safety in Asynchronous Operations
For async operations like token refresh notifications:
@Service
public class AsyncTokenRefreshService {
private final ExecutorService asyncExecutor = Executors.newFixedThreadPool(10);
public CompletableFuture<Void> handleTokenRefresh(String userId) {
return CompletableFuture.runAsync(() -> {
// Thread-safe operation in async context
sendTokenRefreshNotification(userId);
}, asyncExecutor);
}
}
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
participant JWT as JWT Provider
U->>AuthS: 1. Login Request (email, password)
AuthS->>DB: 2. Fetch User by Email
DB-->>AuthS: 3. User Record (hashed password)
AuthS->>JWT: 4. Generate Access Token
JWT-->>AuthS: 5. Signed JWT
AuthS->>Cache: 6. Store Token in Cache (with TTL)
Cache-->>AuthS: 7. Cache Set Success
AuthS-->>U: 8. Return JWT Token + Refresh Token
Note over U,AuthS: Token stored in secure HTTP-only cookie
sequenceDiagram
participant U as User
participant AuthS as Auth Service
participant DB as Database
participant Cache as Redis Cache
participant JWT as JWT Provider
U->>AuthS: 1. Refresh Token Request (refresh token)
AuthS->>Cache: 2. Check if refresh token exists
Cache-->>AuthS: 3. Token Found in Cache
AuthS->>DB: 4. Validate Refresh Token (check DB for revocation)
DB-->>AuthS: 5. Token Valid
AuthS->>JWT: 6. Generate New Access Token
JWT-->>AuthS: 7. Signed JWT
AuthS->>Cache: 8. Update Access Token TTL
Cache-->>AuthS: 9. Cache Updated
AuthS-->>U: 10. Return New JWT Token
Note over U,AuthS: Refresh token is rotated and stored in cache with new TTL
sequenceDiagram
participant U as User
participant AuthS as Auth Service
participant DB as Database
participant Cache as Redis Cache
participant EventBus as Message Broker
U->>AuthS: 1. Logout Request (with refresh token)
AuthS->>Cache: 2. Invalidate Access Token
Cache-->>AuthS: 3. Token Removed from Cache
AuthS->>DB: 4. Revoke Refresh Token (mark as used)
DB-->>AuthS: 5. Token Revoked
AuthS->>Cache: 6. Store Refresh Token in Blacklist (with TTL)
Cache-->>AuthS: 7. Token Blacklisted
AuthS->>EventBus: 8. Publish Logout Event (user_id)
EventBus-->>AuthS: 9. Event Published
AuthS-->>U: 10. Return Success Response
Note over U,AuthS: Async event for cleanup of other sessions or device tracking
7. Caching Design
Cache Strategy Selection
For authentication service, I'll use cache-aside pattern with write-through for session data and read-through for user metadata. This approach provides the best balance between consistency, performance, and complexity.
Cache Key Design
Cache keys will follow a namespace strategy:
user:{userId}:{type} - for user data (profile, permissions)
session:{sessionId} - for active sessions
token:{jwtTokenHash} - for token validation cache
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| User sessions | 30 minutes | Session timeout aligns with JWT expiration |
| User profiles | 1 hour | Frequent reads but less critical to be real-time |
| OAuth tokens | JWT expiration + 5 min | JWTs are self-contained, but we cache for safety |
| Role/permission mappings | 15 minutes | Permissions may change, but not frequently |
Cache Eviction Policy
LRU (Least Recently Used) for session and user profile caches, with TTL-based eviction as safety net. This prevents memory bloat while ensuring freshness.
Cache Stampede Prevention
- Probabilistic early expiry: Set cache TTL to 75% of actual TTL, allowing for 25% overlap
- Distributed locks: Use Redis distributed locks (
SET key value NX EX ttl) for cache warming operations - Cache warming: Pre-warm popular user profiles during low-traffic periods
Distributed Cache Architecture
Using Redis Cluster with:
- 3 master nodes (for high availability)
- 3 replica nodes per master
- Redis Sentinel for automatic failover
- Cache sharding by user ID hash
Cache Invalidation Strategy
Event-driven invalidation using message broker:
- When user profile changes → invalidate
user:{userId}:profile - When session ends → invalidate
session:{sessionId} - When permissions change → invalidate
user:{userId}:permissions - TTL-based invalidation as fallback
Cache Implementation Example (Java)
@Service
public class AuthCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
public void cacheUserSession(String sessionId, AuthSession session) {
String key = "session:" + sessionId;
redisTemplate.opsForValue().set(key, session, 30, TimeUnit.MINUTES);
}
public AuthSession getCachedSession(String sessionId) {
String key = "session:" + sessionId;
return (AuthSession) redisTemplate.opsForValue().get(key);
}
public void invalidateUserCache(String userId) {
// Invalidate all user-related caches
String profileKey = "user:" + userId + ":profile";
String permissionsKey = "user:" + userId + ":permissions";
redisTemplate.delete(profileKey);
redisTemplate.delete(permissionsKey);
}
public void cacheUserPermissions(String userId, Set<String> permissions) {
String key = "user:" + userId + ":permissions";
redisTemplate.opsForValue().set(key, permissions, 15, TimeUnit.MINUTES);
}
public Set<String> getCachedPermissions(String userId) {
String key = "user:" + userId + ":permissions";
return (Set<String>) redisTemplate.opsForValue().get(key);
}
}
8. Repository / Data Access Layer
Repository Pattern Implementation
The authentication service follows the repository pattern to abstract data access logic and provide a clean separation between domain logic and persistence concerns. Each entity has its dedicated repository interface and implementation.
public interface UserRepository {
Optional<User> findById(UUID id);
Optional<User> findByEmail(String email);
User save(User user);
void delete(UUID id);
List<User> findAll();
}
public interface SessionRepository {
Optional<Session> findByToken(String token);
List<Session> findByUserId(UUID userId);
Session save(Session session);
void delete(String token);
void deleteExpiredSessions();
}
ORM vs Raw Query Decisions
For the authentication service, we use JPA/Hibernate as our ORM for most operations due to:
- Type safety: Compile-time checking prevents runtime errors
- Query abstraction: Complex joins and relationships are handled cleanly
- Caching integration: Built-in second-level caching support
- Transaction management: Seamless integration with Spring's transactional model
However, for high-frequency operations like session validation and token lookup, we use raw SQL queries to minimize overhead:
@Repository
public class SessionRepositoryImpl implements SessionRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public Optional<Session> findByToken(String token) {
String sql = "SELECT s FROM Session s WHERE s.token = :token AND s.expiresAt > CURRENT_TIMESTAMP";
TypedQuery<Session> query = entityManager.createQuery(sql, Session.class);
query.setParameter("token", token);
return query.getResultStream().findFirst();
}
@Override
public void deleteExpiredSessions() {
String sql = "DELETE FROM Session s WHERE s.expiresAt <= CURRENT_TIMESTAMP";
entityManager.createQuery(sql).executeUpdate();
}
}
Read vs Write Separation
We implement CQRS pattern for the authentication service to handle read-heavy operations efficiently:
// Read model - optimized for queries
public class UserReadModel {
private UUID id;
private String email;
private String status;
private LocalDateTime lastLoginAt;
// getters and setters
}
// Write model - for mutations
public class User {
private UUID id;
private String email;
private String hashedPassword;
private UserStatus status;
private LocalDateTime lastLoginAt;
// getters and setters
}
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 with JOIN FETCH for critical paths and DataLoader pattern for batch operations:
@Repository
public class UserRepositoryImpl implements UserRepository {
@PersistenceContext
private EntityManager entityManager;
// Prevents N+1 in user session retrieval
@Override
public List<User> findAllWithSessions() {
String jpql = """
SELECT u FROM User u
LEFT JOIN FETCH u.sessions
WHERE u.status = :status
""";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("status", UserStatus.ACTIVE);
return query.getResultList();
}
// DataLoader for batch session validation
public CompletableFuture<List<Session>> getSessionsForUsers(List<UUID> userIds) {
return CompletableFuture.supplyAsync(() -> {
String jpql = """
SELECT s FROM Session s
WHERE s.userId IN :userIds
AND s.expiresAt > CURRENT_TIMESTAMP
""";
TypedQuery<Session> query = entityManager.createQuery(jpql, Session.class);
query.setParameter("userIds", userIds);
return query.getResultList();
});
}
}
Batch Operation Design
For bulk operations like session invalidation or user status updates:
@Repository
public class BatchSessionRepository {
@PersistenceContext
private EntityManager entityManager;
public int invalidateSessionsForUsers(List<UUID> userIds) {
String jpql = "UPDATE Session s SET s.expiresAt = CURRENT_TIMESTAMP WHERE s.userId IN :userIds";
Query query = entityManager.createQuery(jpql);
query.setParameter("userIds", userIds);
return query.executeUpdate();
}
public int deleteExpiredSessionsBatch() {
String jpql = "DELETE FROM Session s WHERE s.expiresAt <= CURRENT_TIMESTAMP";
Query query = entityManager.createQuery(jpql);
return query.executeUpdate();
}
}
Transaction Management
All repository operations are wrapped in proper transaction boundaries:
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private SessionRepository sessionRepository;
public User createUser(CreateUserRequest request) {
// Transaction boundary includes both user creation and session setup
User user = new User();
user.setEmail(request.getEmail());
user.setHashedPassword(hashPassword(request.getPassword()));
User savedUser = userRepository.save(user);
// Create session for new user
Session session = createSessionForUser(savedUser);
sessionRepository.save(session);
return savedUser;
}
}
Query Optimization
We implement custom query hints and indexing strategies for performance:
// Custom query with hints for better performance
@QueryHints(value = {
@QueryHint(name = "org.hibernate.fetchSize", value = "50"),
@QueryHint(name = "org.hibernate.timeout", value = "30")
})
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmailWithHints(@Param("email") String email);
Data Access Layer Testing Strategy
@ExtendWith(MockitoExtension.class)
class UserRepositoryImplTest {
@Mock
private EntityManager entityManager;
@Mock
private TypedQuery<User> queryMock;
@InjectMocks
private UserRepositoryImpl userRepository;
@Test
void findByEmailReturnsUserWhenExists() {
// Given
when(entityManager.createQuery(anyString(), any())).thenReturn(queryMock);
when(queryMock.setParameter(anyString(), any())).thenReturn(queryMock);
when(queryMock.getResultStream()).thenReturn(Stream.of(mockUser));
// When
Optional<User> result = userRepository.findByEmail("test@example.com");
// Then
assertThat(result).isPresent();
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The authentication service will use asynchronous processing for operations that are not time-critical or don't require immediate response to the client. The key boundaries for async processing include:
- Session invalidation and cleanup - When a user logs out or session expires, we asynchronously invalidate related tokens in Redis and clean up session data.
- Audit logging - All authentication events (login, logout, token refresh) will be published to a message queue for audit and analytics.
- Password change notifications - When a user changes their password, we asynchronously notify other services about the session invalidation.
- Token refresh token rotation - When a refresh token is used, we asynchronously update the token metadata and potentially invalidate older tokens.
Synchronous operations include:
- User login with credentials
- Token generation (access and refresh)
- Password reset initiation
- Session validation
Message schema design with sample payload
We'll use Protobuf for message schemas to ensure strong typing and versioning support.
// auth_events.proto
syntax = "proto3";
package auth.events;
message AuthEvent {
string event_id = 1;
string user_id = 2;
string event_type = 3; // "LOGIN", "LOGOUT", "PASSWORD_CHANGED", "TOKEN_REFRESHED"
string session_id = 4;
string ip_address = 5;
string user_agent = 6;
int64 timestamp = 7;
string source_service = 8; // "auth-service"
map<string, string> metadata = 9;
}
message TokenInvalidationEvent {
string user_id = 1;
string session_id = 2;
string token_type = 3; // "ACCESS", "REFRESH"
int64 expiration_time = 4;
string reason = 5; // "LOGOUT", "EXPIRED", "PASSWORD_CHANGED"
}
Versioning strategy:
- Major version: Protocol changes (e.g., breaking changes in event structure)
- Minor version: Non-breaking additions or field renames
- Patch version: Bug fixes and internal changes
Dead letter queue strategy and poison message handling
For failed message processing, we implement a dead-letter queue (DLQ) strategy:
- Retry policy: Messages are retried up to 3 times with exponential backoff (1s, 2s, 4s)
- DLQ: Messages that fail after max retries are sent to a DLQ topic with a retention of 7 days
- Poison message handling:
- Messages that fail consistently (same error pattern) are flagged
- DLQ messages with the same error code and user_id are batched for manual review
- Alerting is triggered when more than 5 poison messages arrive within 1 hour
At-least-once vs. exactly-once delivery
We choose at-least-once delivery for authentication events because:
- Idempotency is maintained at the consumer level:
- Session invalidation operations are idempotent (repeating same operation has same effect)
- Audit logs can be deduplicated by event_id
- Performance considerations:
- Exactly-once requires distributed transactions or complex state management
- For audit logging, duplicate events are acceptable and can be handled gracefully
- Simpler implementation:
- At-least-once is easier to implement and maintain
Retry policy for consumers
Retry strategy:
- Max attempts: 3 retries
- Backoff formula: Exponential backoff with jitter (1s, 2s, 4s)
- Jitter: ±20% randomization to prevent thundering herd
- Non-retryable errors:
- Invalid user_id or session_id (permanent failures)
- Malformed message payloads
- Authorization errors (user not found, etc.)
public class RetryPolicy {
private static final int MAX_RETRIES = 3;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_RANGE = 0.2;
public boolean shouldRetry(int attempt, Exception exception) {
if (attempt >= MAX_RETRIES) return false;
// Don't retry on permanent errors
if (exception instanceof InvalidUserException ||
exception instanceof InvalidSessionException) {
return false;
}
return true;
}
public long getDelay(int attempt) {
double delay = Math.pow(2, attempt) * BASE_DELAY_MS;
double jitter = (Math.random() * 2 - 1) * JITTER_RANGE;
return (long) (delay * (1 + jitter));
}
}
Outbox pattern for reliable event publishing
We do implement the outbox pattern for authentication events to ensure reliability:
- Event persistence: Events are written to an
auth_events_outboxtable before publishing - Transaction boundary: Events are committed to the outbox within the same database transaction as the primary operation
- Background processor: A dedicated service polls the outbox table and publishes messages to the message broker
- Idempotency: Events are marked as published once successfully delivered
@Entity
@Table(name = "auth_events_outbox")
public class AuthEventOutbox {
@Id
private UUID id;
@Column(name = "event_payload")
private String payload;
@Column(name = "status")
private String status; // "PENDING", "PUBLISHED", "FAILED"
@Column(name = "retry_count")
private int retryCount;
@Column(name = "created_at")
private Instant createdAt;
}
Sequence diagram for async flows
sequenceDiagram
participant AuthService
participant DB
participant OutboxService
participant MessageBroker
participant AuditService
AuthService->>DB: Begin transaction
AuthService->>DB: Create user session
AuthService->>DB: Save auth event to outbox table
AuthService->>DB: Commit transaction
OutboxService->>OutboxService: Poll outbox table for pending events
OutboxService->>MessageBroker: Publish event to auth.events topic
MessageBroker->>AuditService: Deliver to audit service
Note over OutboxService,MessageBroker: Async processing
The outbox pattern ensures that authentication events are reliably published even if the message broker is temporarily unavailable, and provides exactly-once delivery guarantees for the database layer.
10. Error Handling Design
Error Classification Hierarchy
The authentication service follows a structured error classification system:
public enum ErrorCode {
// Validation errors (400)
INVALID_CREDENTIALS,
MISSING_REQUIRED_FIELD,
INVALID_EMAIL_FORMAT,
PASSWORD_TOO_SHORT,
// Business logic errors (400)
USER_NOT_FOUND,
ACCOUNT_LOCKED,
INVALID_OTP,
SESSION_EXPIRED,
// Infrastructure errors (500)
DATABASE_ERROR,
CRYPTOGRAPHIC_ERROR,
EXTERNAL_SERVICE_UNAVAILABLE,
// Authorization errors (403)
INSUFFICIENT_PERMISSIONS,
UNAUTHORIZED_ACCESS
}
Error Propagation Strategy
Errors are handled at the appropriate layer with specific strategies:
- Validation errors - Caught and converted to
BadRequestExceptionat API boundary - Business logic errors - Thrown as domain-specific exceptions and mapped to appropriate HTTP status codes
- Infrastructure errors - Transient errors (DB timeouts, network issues) are retried before bubbling up as
ServiceUnavailableException - Authorization errors - Mapped to 403 responses with proper error codes
Retry Strategy
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 = 100;
public <T> T executeWithRetry(Supplier<T> operation) {
int attempts = 0;
while (attempts < MAX_RETRIES) {
try {
return operation.get();
} catch (DatabaseException e) {
if (attempts < MAX_RETRIES - 1) {
long delay = BASE_DELAY_MS * Math.pow(2, attempts) +
ThreadLocalRandom.current().nextLong(0, 100);
Thread.sleep(delay);
attempts++;
} else {
throw new ServiceUnavailableException("Database unavailable after retries", e);
}
}
}
throw new ServiceUnavailableException("Operation failed after all retries");
}
}
Circuit Breaker Design
For external service dependencies (e.g., email service):
public class CircuitBreaker {
private final int failureThreshold = 5;
private final long timeout = 60000; // 1 minute
private final int resetTimeout = 30000; // 30 seconds
private int failureCount = 0;
private long lastFailureTime = 0;
private CircuitState state = CircuitState.CLOSED;
public <T> T execute(Supplier<T> operation) {
if (state == CircuitState.OPEN) {
if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
state = CircuitState.HALF_OPEN;
} else {
throw new ServiceUnavailableException("External service circuit is open");
}
}
try {
T result = operation.get();
if (state == CircuitState.HALF_OPEN) {
state = CircuitState.CLOSED;
}
return result;
} catch (Exception e) {
handleFailure();
throw e;
}
}
private void handleFailure() {
lastFailureTime = System.currentTimeMillis();
failureCount++;
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN;
}
}
}
Fallback Behavior Design
For critical external dependencies:
public class ExternalServiceFallback {
private final EmailService emailService;
private final NotificationService notificationService;
public void sendVerificationEmail(String email, String token) {
try {
emailService.sendVerificationEmail(email, token);
} catch (ExternalServiceException e) {
// Fallback to notification service if email fails
notificationService.sendPushNotification(email, "Verification required");
}
}
}
Error Logging Format
Structured logging with consistent fields:
public class AuthErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(AuthErrorLogger.class);
public void logError(String operation, String userId, ErrorCode code,
Exception exception, Map<String, Object> additionalFields) {
Map<String, Object> logFields = new HashMap<>();
logFields.put("timestamp", Instant.now().toString());
logFields.put("operation", operation);
logFields.put("userId", userId);
logFields.put("errorCode", code.name());
logFields.put("errorMessage", exception.getMessage());
logFields.put("stackTrace", exception.getStackTrace().toString());
logFields.putAll(additionalFields);
logger.error("Authentication service error",
MarkerFactory.getMarker("AUTH_ERROR"),
logFields);
}
}
Alerting Thresholds
Critical error thresholds:
| Error Type | Alert Level | Threshold | Action |
|---|---|---|---|
| Database errors | Critical | >5 occurrences/minute | On-call alert |
| Authentication failures | Warning | >100 occurrences/hour | Alert team |
| External service unavailability | Critical | >3 consecutive failures | On-call alert |
| Invalid OTP attempts | Warning | >50 attempts/hour | Alert security team |
Error Response Format
Standardized JSON error response:
{
"error": {
"code": "INVALID_CREDENTIALS",
"message": "Invalid username or password",
"timestamp": "2023-10-15T14:30:00Z",
"requestId": "req-123456789"
}
}
Error Handling in Service Layer
public class AuthenticationService {
public AuthResponse authenticate(AuthRequest request) {
try {
validateRequest(request);
User user = userRepository.findByEmail(request.getEmail());
if (user == null) {
throw new UserNotFoundException("User not found");
}
if (!passwordService.verify(request.getPassword(), user.getHashedPassword())) {
throw new InvalidCredentialsException("Invalid credentials");
}
return createAuthResponse(user);
} catch (ValidationException e) {
throw new BadRequestException("Invalid input", e);
} catch (UserNotFoundException e) {
// Log failed authentication attempt for security monitoring
logFailedAuthAttempt(request.getEmail());
throw new UnauthorizedException("Invalid credentials");
} catch (DatabaseException e) {
logger.error("Database error during authentication", e);
throw new ServiceUnavailableException("Authentication service temporarily unavailable");
}
}
}
11. Security Design
Input Validation and Sanitization Strategy
All inputs are validated using a combination of:
- Regex patterns for format validation (e.g., email format, password complexity)
- Length limits to prevent buffer overflow attacks
- Whitelisting for allowed characters in usernames and identifiers
- Custom validation rules using Apache Commons Validator for complex inputs like phone numbers or country codes
public class InputValidator {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
public static boolean isValidEmail(String email) {
return email != null && EMAIL_PATTERN.matcher(email).matches();
}
public static boolean isValidPassword(String password) {
return password != null &&
password.length() >= 8 &&
password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$");
}
}
SQL Injection Prevention
All database queries use parameterized prepared statements exclusively. No dynamic query building or string concatenation is allowed.
// ❌ BAD - Vulnerable to SQL injection
String query = "SELECT * FROM users WHERE email = '" + email + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);
// ✅ GOOD - Parameterized query
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement stmt = connection.prepareStatement(query);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();
Authentication Middleware Design
Authentication middleware intercepts all requests and validates JWT tokens using HS256 algorithm. The middleware:
- Extracts the token from Authorization header
- Verifies signature and expiration
- Validates audience and issuer claims
- Sets authenticated user context in request scope
@Component
public class JwtAuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String authHeader = httpRequest.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
// Set authenticated user in security context
Authentication auth = new JwtAuthenticationToken(claims);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
throw new AuthenticationException("Invalid token", e);
}
}
chain.doFilter(request, response);
}
}
Authorization Enforcement
Role-Based Access Control (RBAC) is implemented at service layer:
- Users have roles (USER, ADMIN, SERVICE_ACCOUNT)
- Permissions are mapped to roles via a permission matrix
- Each service method checks user permissions before execution
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(String userId) {
// Only ADMIN can delete users
}
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public void updateUserProfile(String userId, UserProfile profile) {
// Users can update their own profile or admins can update any
}
Sensitive Data Encryption
All sensitive data is encrypted at rest using AES-256:
- Passwords are salted and hashed with BCrypt (cost factor 12)
- JWT tokens are encrypted using AES-GCM for sensitive claims
- API keys and secrets stored in encrypted format
@Service
public class EncryptionService {
private final SecretKey secretKey;
public String encrypt(String plaintext) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(plaintext.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new EncryptionException("Encryption failed", e);
}
}
public String decrypt(String encryptedText) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
return new String(decrypted);
} catch (Exception e) {
throw new EncryptionException("Decryption failed", e);
}
}
}
PII Handling and Masking Strategy
All Personally Identifiable Information (PII) is:
- Stored in encrypted form
- Masked in logs and error messages
- Removed from non-essential system logs after retention period
public class PiiMasker {
public static String maskEmail(String email) {
if (email == null || email.isEmpty()) return email;
int atIndex = email.indexOf('@');
if (atIndex <= 0) return email;
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex);
// Mask middle part of local part
if (localPart.length() > 2) {
String maskedLocal = localPart.charAt(0) +
"****" +
localPart.charAt(localPart.length() - 1);
return maskedLocal + domain;
}
return email; // Don't mask if too short
}
}
Secrets Management
Secrets are managed using AWS Secrets Manager:
- API keys, database passwords, and JWT signing keys stored as secrets
- Rotation policy: 90-day automatic rotation for all secrets
- Access control based on IAM roles and policies
@Service
public class SecretsManager {
private final AWSSecretsManager secretsManager;
public String getSecret(String secretName) {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId(secretName)
.build();
return secretsManager.getSecretValue(request).secretString();
}
}
Audit Logging Design
All security-relevant events are logged with:
- Timestamp and user identity (masked for PII)
- Event type (login, logout, password change, etc.)
- IP address and user agent
- Session ID and device fingerprint
@Component
public class SecurityAuditLogger {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditLogger.class);
public void logSecurityEvent(String eventType, String userId,
String ipAddress, String userAgent) {
Map<String, Object> auditEvent = new HashMap<>();
auditEvent.put("timestamp", Instant.now().toString());
auditEvent.put("event_type", eventType);
auditEvent.put("user_id", maskUserId(userId));
auditEvent.put("ip_address", ipAddress);
auditEvent.put("user_agent", userAgent);
logger.info("Security event: {}", auditEvent);
}
private String maskUserId(String userId) {
return userId != null ? userId.substring(0, 2) + "****" : "unknown";
}
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets (%)
- Service Layer: 85% code coverage, focusing on business logic and edge case handling
- Repository Layer: 90% coverage with mock database interactions
- Controller Layer: 75% coverage for core authentication flows (login, register, refresh)
- Security Components: 95% coverage for validation logic and authentication middleware
- Domain Objects: 100% coverage for value objects and immutable entities
Integration Testing Strategy
- Database Integration: Test with embedded H2/PostgreSQL in-memory databases for all CRUD operations
- External Service Mocking: Use WireMock or Mockito to simulate OAuth providers (Google, GitHub) and JWT token services
- End-to-End Flow Testing: Validate complete authentication cycles (registration → login → token refresh → logout) using testcontainers for real database instances
- Cross-Service Integration: Test token validation against other services using shared test JWT keys
Contract Testing
- API Contract Testing: Use Pact for consumer-driven contract testing between authentication service and dependent services (e.g., user service, dashboard)
- Event Contract Testing: Define Avro schemas for authentication events (user_registered, token_refreshed) and validate consumer contracts with Pact Broker
- Version Compatibility: Test backward compatibility of API endpoints across versions using contract testing matrix
Performance Testing Tool and Key Scenarios
- Tool: k6 (JavaScript-based load testing) with Grafana for visualization and alerting
- Key Scenarios:
- Concurrent login requests (1000 users/s) with 95th percentile < 200ms
- Token refresh throughput (5000 requests/s) with < 100ms latency
- Registration burst (500 concurrent users) with < 500ms response time
- JWT token validation under high load (10,000/sec) with < 50ms latency
Test Data Management Strategy
- Fixtures: Use JSON/YAML fixtures for common user profiles (admin, regular user, guest) with predefined credentials
- Factories: Implement Factory pattern for generating test users with unique identifiers and randomized attributes
- Seeding: Use database migration scripts to seed test data in development environments, ensuring clean state for each test run
- Test Data Isolation: Use database schemas or prefixes to isolate test data from production data in shared environments
// Example test case for authentication service using JUnit 5 and Mockito
@Test
void authenticateUserWithValidCredentials_ShouldReturnToken() {
// Given
User user = UserFactory.createActiveUser("john.doe@example.com", "password123");
when(userRepository.findByEmail("john.doe@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", user.getPassword())).thenReturn(true);
// When
AuthenticationResponse response = authService.authenticate(
new LoginRequest("john.doe@example.com", "password123")
);
// Then
assertThat(response).isNotNull();
assertThat(response.getAccessToken()).isNotBlank();
assertThat(response.getRefreshToken()).isNotBlank();
}
13. Observability Design
Structured Logging Schema
All logs follow a structured JSON format with consistent fields to enable programmatic parsing and filtering:
{
"timestamp": "2023-10-27T14:30:45.123Z",
"level": "INFO|WARN|ERROR",
"service": "auth-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user-12345",
"request_id": "req-98765",
"operation": "authenticateUser",
"message": "User authentication successful",
"duration_ms": 150,
"error_code": "AUTH_001",
"error_message": "Invalid credentials provided",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"endpoint": "/auth/login",
"method": "POST"
}
PII masking rules:
- User email addresses are masked (e.g.,
user@example.com→u***@example.com) - Passwords and tokens are completely redacted
- IP addresses may be masked to subnet level for privacy compliance
Distributed Tracing Instrumentation
Using OpenTelemetry with custom spans and attributes:
Key Operations to Trace:
auth.authenticateUser- Entry point for login flowauth.validateToken- JWT validation and refresh logicauth.generateToken- Token issuance with claimsauth.revokeSession- Session invalidation flow
Attributes Captured Per Span:
// Example custom span attributes for authentication flow
span.setAttribute("auth.user_id", userId);
span.setAttribute("auth.ip_address", clientIp);
span.setAttribute("auth.auth_method", "oauth2|jwt|password");
span.setAttribute("auth.token_type", "access|refresh");
span.setAttribute("auth.session_id", sessionId);
span.setAttribute("auth.scopes_requested", String.join(",", scopes));
Metrics Design
RED Method Metrics (Rate, Errors, Duration)
| Metric Name | Type | Labels | Description |
|---|---|---|---|
auth_requests_total | Counter | {method, endpoint, status_code} | Total request count |
auth_requests_duration_seconds | Histogram | {method, endpoint, status_code} | Request processing time |
auth_errors_total | Counter | {method, endpoint, error_type} | Error count by type |
auth_active_sessions | Gauge | {user_id} | Active session count |
auth_token_refreshes_total | Counter | {source} | Token refresh attempts |
Additional Service-Level Metrics:
auth_login_attempts_total- Counter by auth methodauth_failed_auths_total- Counter by reason (invalid credentials, expired token)auth_session_expiry_seconds- Histogram of session durations
Health Check Endpoint Design
Endpoints with Expected Responses:
GET /health
Content-Type: application/json
{
"status": "healthy",
"timestamp": "2023-10-27T14:30:45.123Z",
"service": "auth-service",
"version": "v1.2.3",
"dependencies": {
"database": "healthy",
"redis": "healthy",
"keycloak": "healthy"
}
}
GET /ready
Content-Type: application/json
{
"status": "ready",
"timestamp": "2023-10-27T14:30:45.123Z",
"service": "auth-service",
"database_connection": true,
"cache_connection": true
}
GET /live
Content-Type: application/json
{
"status": "alive",
"timestamp": "2023-10-27T14:30:45.123Z",
"uptime_seconds": 3600
}
Alerting Thresholds
Critical Alerts (Escalation Path: On-call engineer → Engineering Manager):
| Metric | Threshold | Alert Level | Escalation |
|---|---|---|---|
auth_requests_duration_seconds p99 | > 500ms | Warning | Critical |
auth_errors_total rate (5m) | > 100/minute | Critical | On-call |
auth_requests_total rate (5m) | < 10/minute | Critical | On-call |
auth_database_connection_failures | > 5 in 10m | Critical | On-call |
auth_cache_miss_rate | > 80% | Warning | On-call |
Warning Alerts (Escalation Path: On-call engineer):
| Metric | Threshold | Alert Level |
|---|---|---|
auth_requests_duration_seconds p99 | > 200ms | Warning |
auth_errors_total rate (5m) | > 20/minute | Warning |
auth_cache_miss_rate | > 50% | Warning |
Monitoring Configuration:
- Alerts configured via Prometheus rules with Slack integration
- Alertmanager groups related alerts by service and severity
- All critical alerts have a 5-minute silence period to prevent noise
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Multi-tenancy support: Should the authentication service support multiple organizations/tenants within a single instance? This affects data isolation, user management, and shared resources like tokens or sessions.
- Password policy enforcement: What level of complexity should be enforced for passwords (length, character sets, history)? Should this be configurable per tenant or globally?
- MFA implementation strategy: Should MFA be mandatory for all users or configurable by role? How should it integrate with existing authentication flows?
- Token lifecycle management: Should refresh tokens be long-lived or short-lived? How should token revocation work across distributed systems?
Known trade-offs and justifications
-
Token-based vs. session-based authentication:
- Trade-off: Token-based (JWT) provides stateless authentication but increases payload size and requires token revocation mechanisms; session-based allows for easy invalidation but requires state management.
- Justification: JWTs chosen for horizontal scalability and microservices compatibility, with refresh token rotation to mitigate token lifetime risks.
-
Database vs. in-memory storage for tokens:
- Trade-off: In-memory storage offers better performance but loses tokens on restart; database storage provides persistence but adds latency.
- Justification: Database storage selected for production resilience, with caching layer to maintain performance.
-
Centralized vs. distributed session management:
- Trade-off: Centralized (Redis) provides consistency but creates a single point of failure; distributed approaches increase complexity.
- Justification: Redis cluster chosen for high availability and horizontal scaling capability.
Risks and mitigation strategies
-
Token replay attacks:
- Risk: JWTs can be replayed if not properly validated for freshness and audience.
- Mitigation: Implement token expiration, audience validation, and refresh token rotation with database checks.
-
High concurrent login rate:
- Risk: Concurrent login bursts can overwhelm database or cache systems.
- Mitigation: Implement rate limiting, circuit breaker patterns, and async processing for token generation.
-
Data loss in distributed environment:
- Risk: Token storage or session data can be lost during system failures.
- Mitigation: Use Redis cluster with persistence, database-backed token storage, and regular backups.
-
Cross-site request forgery (CSRF):
- Risk: Stateful authentication flows are vulnerable to CSRF attacks.
- Mitigation: Implement CSRF tokens for session-based flows and use secure, SameSite cookies.
Technical debt acknowledged
- Hardcoded security policies: Password complexity and token TTLs are currently hardcoded, requiring code changes for policy updates.
- Limited support for external identity providers: OAuth2 integration is minimal and doesn't support enterprise SSO providers like Okta or Azure AD.
- No token introspection endpoint: While JWTs are used, there's no standard introspection endpoint for checking token validity in real-time.
- Session management limitations: Session invalidation is not fully atomic across distributed services, which could lead to race conditions during logout.
- No support for token binding: Tokens are not bound to specific client IPs or user agents, increasing security surface.
- Limited audit logging granularity: Audit logs capture basic events but lack detailed context for forensic analysis.