Skip to main content
the auth layer

The Multi-Tenant SaaS Platform and the Auth Boundaries It Exposes

6 min read Chapter 3 of 45

The Multi-Tenant SaaS Platform and the Auth Boundaries It Exposes

The Five Components

The platform that runs through this book is a multi-tenant SaaS system with five components, each presenting a different auth problem.

Core API

Spring Boot 3 application serving the central business logic. Handles CRUD operations for tenant-scoped resources: projects, documents, billing, user management within a tenant. Every request must carry both user identity and tenant context. The API validates tokens, extracts tenant claims, and enforces that the authenticated user belongs to the tenant they are accessing.

Technology: Java 21, Spring Boot 3, Spring Security, Spring Data JPA, PostgreSQL with row-level security.

Auth boundary: Accepts bearer tokens from the frontend shell, mobile client, and third-party integrations. Validates JWTs locally for internal service calls. Uses opaque tokens with introspection for external client requests where instant revocation matters.

Frontend Shell

A single-page application that enterprise users access in their browsers. Renders dashboards, data views, and administrative interfaces. Authenticates users via the authorization code flow with PKCE, receives tokens, and stores them in memory (not localStorage, not sessionStorage).

Auth boundary: The browser-to-API boundary is the highest-risk boundary in the system. Browsers are hostile environments: XSS can steal tokens from JavaScript-accessible storage, CSRF can trigger state-changing requests using ambient cookies, and shared devices mean a session left alive is a session available to the next user.

The frontend shell uses session-based authentication with the Core API’s BFF (Backend For Frontend) layer. The BFF holds the tokens server-side and issues a secure, HttpOnly session cookie to the browser. The browser never sees a JWT.

Mobile Client

Native applications for iOS and Android. Authenticate via authorization code flow with PKCE using system browsers (no embedded WebViews, which enable credential interception). Store refresh tokens in platform-secure storage: iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, Android Keystore with biometric binding.

Auth boundary: Mobile-to-API uses bearer JWTs because the mobile client is a trusted environment (secure storage exists) and network latency makes introspection on every request impractical. Refresh tokens have longer lifetimes (days to weeks) but are bound to the device and rotated on every use.

The primary threat is device theft. When a device is reported stolen, the server must revoke all refresh tokens for that device immediately. This requires server-side refresh token storage, which is why refresh tokens are always opaque and stored in Redis, never self-contained JWTs.

Third-Party Integrations

External companies that access tenant data through the platform’s public API. Each integration is registered as an OAuth2 client by a tenant administrator. The administrator grants specific scopes (read:projects, write:documents) and the integration cannot exceed those scopes regardless of what it requests.

Auth boundary: Third-party clients authenticate using either client credentials (for server-to-server access without a user context) or authorization code (for access on behalf of a specific user). Each token carries the tenant_id of the tenant that authorized the integration. A third-party client authorized by Tenant A cannot access Tenant B’s data even if it guesses valid resource IDs.

The primary threat is scope creep and confused deputy attacks. A malicious integration might attempt to use a token granted by Tenant A to access Tenant B’s resources by manipulating request parameters.

Internal Microservices

Payment processing, notification delivery, analytics pipeline, search indexing. These services communicate with each other and with the Core API. They never interact with end users directly.

Auth boundary: Service-to-service communication uses mTLS with SPIFFE/SPIRE for transport-layer identity. Each service has a SPIFFE ID (spiffe://prod/payment-service) that identifies it cryptographically without shared secrets. When a service acts on behalf of a user (processing a payment initiated by a user request), it uses token exchange (RFC 8693) to obtain a scoped token that identifies both the service and the original user.

The primary threat is lateral movement: a compromised service using its identity to access other services beyond its legitimate scope.

The Auth Boundary Map

Multi-tier auth boundary map showing Internet clients, Core API/BFF with per-path SecurityFilterChain, and internal services connected via mTLS

The diagram maps every trust boundary in the SaaS platform. Browser traffic uses session cookies routed through the BFF pattern. Mobile and third-party clients present bearer JWTs validated per-path by separate SecurityFilterChain configurations. Internal services authenticate via mTLS with SPIFFE SVIDs and use token exchange (RFC 8693) to propagate user context without forwarding the original token. Each boundary enforces different authentication mechanisms because each faces different threat models.

Threat Model Per Boundary

Browser → Core API: XSS token theft (mitigated by keeping tokens server-side in BFF), CSRF (mitigated by SameSite cookies and CSRF tokens), session fixation (mitigated by session ID rotation on authentication), session hijacking (mitigated by secure cookie attributes and short session TTL).

Mobile → Core API: Token theft from compromised device (mitigated by platform-secure storage and device binding), refresh token replay (mitigated by rotation with replay detection), network interception (mitigated by certificate pinning).

Third-Party → Core API: Scope elevation (mitigated by server-enforced scope limits), confused deputy / cross-tenant access (mitigated by tenant_id in token claims and server-side enforcement), token replay (mitigated by audience restriction and short lifetime).

Service → Service: Lateral movement (mitigated by SPIFFE ID-based authorization policies), confused deputy (mitigated by explicit token exchange with audience scoping), credential theft (mitigated by mTLS with automatic certificate rotation, no shared secrets).

Spring Security Configuration Per Boundary

@Configuration
@EnableWebSecurity
public class MultiLayerSecurityConfig {

    // Browser-facing: session-based with CSRF
    @Bean
    @Order(1)
    public SecurityFilterChain browserFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/app/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .oauth2Login(Customizer.withDefaults())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(3)
            )
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            .build();
    }

    // Mobile/SPA API: JWT validation, stateless
    @Bean
    @Order(2)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                    .jwtAuthenticationConverter(tenantAwareJwtConverter())
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable())
            .build();
    }

    // Third-party: opaque token introspection
    @Bean
    @Order(3)
    public SecurityFilterChain partnerFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/partner/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(opaque -> opaque
                    .introspectionUri(introspectionUri)
                    .introspectionClientCredentials(clientId, clientSecret)
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable())
            .build();
    }

    // Internal services: JWT with strict audience validation
    @Bean
    @Order(4)
    public SecurityFilterChain internalFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/internal/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(strictAudienceJwtDecoder())
                )
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .csrf(csrf -> csrf.disable())
            .build();
    }
}

Four SecurityFilterChain beans, ordered by specificity, each configured for its boundary’s threat model. The browser path has sessions and CSRF. The mobile API path has JWTs and is stateless. The partner path uses opaque tokens with introspection for instant revocation capability. The internal path uses JWTs with strict audience validation to prevent token replay across services.

This configuration is the skeleton that every subsequent chapter builds upon. Chapter 4 fills in the JWT decoder details. Chapter 6 explains the opaque token introspection path. Chapter 7 configures the session store. Chapter 8 adds tenant isolation to every layer.