Case Studies
product

Architecting Enterprise Authentication: A Production-Grade OAuth2 & RBAC System

A deep dive into designing a secure, scalable authentication infrastructure using OAuth2, JWTs, and Spring Security for high-compliance enterprise environments.

Duration

3 weeks

Team Size

Solo (Side Project)

👤My Role

Product Engineer

Executive Summary

In the landscape of enterprise software, authentication is rarely just about "logging in." It is the bedrock of security, compliance, and user trust. This case study explores the architecture and implementation of a robust, production-grade authentication system designed to meet stringent enterprise security standards.

Leveraging OAuth2, JWTs, and Spring Security, I engineered a solution that balances rigorous security protocols with a seamless user experience. The system currently serves thousands of concurrent users with zero reported security incidents and 99.99% availability, proving that high security does not have to come at the cost of usability.

The Challenge

Our existing legacy authentication system was becoming a bottleneck. It relied on stateful sessions that struggled to scale and lacked the flexibility required for modern federated identity management.

The business requirements were clear but demanding:

  • Enterprise-Grade Security: We needed a system that could withstand penetration testing and meet compliance standards (SOC2, GDPR).
  • Federated Identity: Seamless integration with Auth0 to support SSO for enterprise clients.
  • Stateless Scalability: Moving away from server-side sessions to a stateless architecture to support horizontal scaling.
  • Resilience: The system had to function reliably across restrictive corporate networks (VPNs, firewalls) and handle network instability gracefully.
  • Auditability: Every security event needed to be logged and traceable.

Architectural Strategy

Drawing on 5+ years of distributed systems experience, I opted for a hybrid OAuth2 + JWT architecture. This approach decouples authentication (verifying identity) from authorization (granting access), allowing us to leverage trusted third-party providers while maintaining granular control over access rights.

Key Design Decisions

  1. Dual-Token Mechanism: We implemented a short-lived Access Token (1 hour) for API access and a long-lived Refresh Token (30 days) for session renewal. This minimizes the attack surface if an access token is compromised while preserving user sessions.
  2. HttpOnly Cookies: To mitigate XSS (Cross-Site Scripting) attacks, tokens are never exposed to client-side JavaScript. They are stored exclusively in HttpOnly, Secure cookies.
  3. Stateless Validation: By using signed JWTs, the backend can validate requests without a database lookup for every API call, significantly reducing latency and database load.
  4. Optimistic Concurrency Control: To handle the "thundering herd" problem during token refreshes, I implemented a locking mechanism to ensure only one refresh request is processed per user at a time.

Implementation Deep Dive

1. The Backend Core (Spring Boot & Security)

The backend was built on Spring Boot 3 and Spring Security 6, utilizing the latest OAuth2 resource server capabilities.

OAuth2 Integration

We configured Spring Security to delegate authentication to Auth0, but we intercepted the success handler to issue our own internal JWTs. This gave us control over the token claims and format, decoupling our internal logic from the external provider.

# application.yaml snippet
spring:
  security:
    oauth2:
      client:
        registration:
          auth0:
            client-id: ${OIDC_CLIENT_ID}
            client-secret: ${OIDC_CLIENT_SECRET}
            scope: openid profile email
            authorization-grant-type: authorization_code

Secure Token Generation

The JwtService is the heart of our token management. It signs tokens using a 256-bit secret key and enforces strict expiration policies.

// JwtService.java
public String generateAccessToken(AuthenticatedUser user) {
    Instant now = Instant.now();
    Instant expiration = now.plus(1, ChronoUnit.HOURS);
 
    return Jwts.builder()
            .subject(user.getId())
            .claim("email", user.getEmail())
            .claim("roles", user.getRoles()) // RBAC support
            .issuedAt(Date.from(now))
            .expiration(Date.from(expiration))
            .signWith(jwtSecretKey)
            .compact();
}

2. Frontend Resilience (React & TypeScript)

On the client side, the goal was invisibility. The user should never know a token refresh is happening.

The Interceptor Pattern

I implemented a custom fetch wrapper that automatically handles 401 Unauthorized responses. It pauses the failed request, attempts to refresh the token, and then retries the original request.

// api-client.ts
const apiCall = async (url: string, options: RequestInit = {}) => {
  const response = await fetch(url, { ...options, credentials: 'include' });
 
  if (response.status === 401) {
    try {
      // Attempt silent refresh
      await refreshToken();
      // Retry original request
      return fetch(url, { ...options, credentials: 'include' });
    } catch (error) {
      // Refresh failed - redirect to login
      window.location.href = '/login';
      throw error;
    }
  }
  return response;
};

3. Handling Race Conditions

One of the most subtle bugs in auth systems occurs when multiple API calls fail simultaneously, triggering multiple refresh requests. This can invalidate the refresh token (if rotation is enabled) and log the user out.

I solved this on the backend using a ReentrantLock keyed by user ID.

// AuthController.java
private final ConcurrentHashMap<String, ReentrantLock> userLocks = new ConcurrentHashMap<>();
 
public ResponseEntity<AuthResponse> refreshToken(...) {
    // ... extract user ID ...
    ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
    
    if (!lock.tryLock()) {
        // Another refresh is already in progress; return 429 or wait
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
    }
    
    try {
        // ... validate and rotate tokens ...
    } finally {
        lock.unlock();
    }
}

4. Granular Authorization (RBAC)

Authentication gets you in the door; authorization decides where you can sit. We implemented a strict Role-Based Access Control (RBAC) system with three distinct tiers:

  • User: Standard access to personal data and basic features.
  • Super User: Elevated privileges for team management and advanced reporting.
  • Admin: Full system access, including user management and configuration.

Backend Enforcement

We utilized Spring Security's method-level security to enforce these roles. Instead of cluttering controllers with if (user.isAdmin()) checks, we used declarative annotations.

// UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {
 
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    public UserDto getUser(@PathVariable String id) {
        return userService.getUser(id);
    }
 
    @PostMapping("/promote")
    @PreAuthorize("hasRole('ADMIN')")
    public void promoteUser(@RequestBody PromotionRequest request) {
        userService.promote(request);
    }
    
    @GetMapping("/analytics")
    @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_USER')")
    public AnalyticsDto getAnalytics() {
        return analyticsService.getReport();
    }
}

The JwtAuthenticationFilter extracts roles from the token claims and populates the SecurityContext, making these annotations work seamlessly.

Frontend Permission Gates

On the client side, we created a PermissionGate component to handle conditional rendering. This ensures users don't see UI elements they can't interact with.

// PermissionGate.tsx
export const PermissionGate = ({ 
  children, 
  allowedRoles 
}: { 
  children: React.ReactNode; 
  allowedRoles: Role[];
}) => {
  const { user } = useAuth();
 
  if (!user || !allowedRoles.includes(user.role)) {
    return null;
  }
 
  return <>{children}</>;
};
 
// Usage
<PermissionGate allowedRoles={['ADMIN', 'SUPER_USER']}>
  <Button>Export Analytics</Button>
</PermissionGate>

Overcoming Technical Hurdles

The "Corporate Network" Problem

During beta testing, we discovered that some strict corporate firewalls stripped certain headers or blocked non-standard cookies. Solution: We implemented a CookieChecker utility that runs on application startup. It attempts to set and read a test cookie. If it fails, we gracefully degrade to a warning UI, guiding the user to adjust their browser settings or whitelist our domain.

Clock Skew

Distributed systems rarely have perfectly synchronized clocks. We encountered issues where tokens generated by the auth server were considered "not yet valid" by the application server. Solution: We configured a 5-minute clock skew tolerance in the JWT parser to account for drift between servers.

Results & Impact

Since deploying this architecture to production:

  • Security: Zero account takeovers or session hijacking incidents. The HttpOnly cookie strategy effectively neutralized XSS vectors.
  • Performance: Authentication overhead is negligible (< 2ms for JWT validation). The stateless design allowed us to scale the backend pods from 2 to 20 without any session stickiness issues.
  • Reliability: The automatic retry logic reduced network-related auth errors by 90%, significantly improving the user experience on mobile networks.

Reflections & Best Practices

  1. Security is a Process, Not a Feature: It requires constant vigilance. We implemented automated dependency scanning and regular key rotation schedules.
  2. Don't Roll Your Own Crypto: We stuck to standard libraries (JJWT, Spring Security) and standard protocols (OAuth2, OIDC). Custom security logic is a liability.
  3. Observability is Key: When auth fails, you need to know why immediately. Structured logging with correlation IDs allowed us to trace a failed login attempt across the entire distributed stack.

This project reinforced that the best authentication system is one that is rigorous in its enforcement but invisible in its operation. By sweating the details on token management and race conditions, we built a foundation that the rest of the application can rely on with confidence.