Multitenant Spring Authorization Server
Modern SaaS platforms often serve multiple customers (tenants) using a single deployment. Each tenant may require isolated OAuth2 clients, users, scopes, tokens, and configurations. Spring Authorization Server (SAS) provides a powerful foundation for OAuth2 and OpenID Connect, but it is single-tenant by default. Let us delve into understanding spring multitenancy.
1. Understanding Multitenancy in Spring Authorization Server
Spring Authorization Server is the official replacement for Spring Security OAuth and serves as the foundation for implementing OAuth2 and OpenID Connect–compliant authorization flows in Spring-based applications. It provides a secure, extensible framework for issuing and managing tokens across different clients and users. Key capabilities include:
- OAuth2 Authorization Server support
- OpenID Connect (OIDC) provider functionality
- JWT and access token customization
- Client registration, consent handling, and token lifecycle management
At its core, Spring Authorization Server relies on a set of pluggable components that allow customization and extension based on application needs:
RegisteredClientRepository– manages OAuth2 client registrationsOAuth2AuthorizationService– persists authorization and token dataOAuth2AuthorizationConsentService– handles user consent approvals
These components can be backed by in-memory, JDBC, or custom implementations, making them well-suited for advanced use cases such as multitenancy.
1.1 Why Multitenancy Matters for OAuth2-based SaaS Platforms
Multitenancy refers to the ability of a single authorization server instance to securely serve multiple tenants while maintaining strict logical isolation between them. Each tenant behaves as an independent security domain within the same deployment. In a multitenant authorization setup, tenants typically require:
- Isolated OAuth2 client registrations
- Separate user identities and credentials
- Tenant-aware tokens carrying tenant-specific claims
Common multitenancy models include:
- Database per tenant – strongest isolation, higher operational cost
- Schema per tenant – balanced isolation and manageability
- Shared database with a tenant discriminator – efficient and scalable (used in this approach)
In the shared database model, tenant context is resolved at runtime and propagated through the authorization flow, ensuring that clients, users, consents, and tokens are always scoped to the correct tenant.
2. Building a Multitenant Authorization Server
2.1 Project Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
This set of dependencies configures the core security and persistence stack for the project: spring-boot-starter-security provides foundational authentication and authorization support, spring-security-oauth2-authorization-server enables building an OAuth2 authorization server, spring-boot-starter-data-jpa adds JPA-based data access and ORM support, and the h2 dependency supplies an in-memory database commonly used for development and testing.
2.2 Resolving Tenant Context per Request
// TenantContext.java
package com.example.multitenant.util;
public final class TenantContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
public static void set(String tenant) {
TENANT.set(tenant);
}
public static String get() {
return TENANT.get();
}
public static void clear() {
TENANT.remove();
}
}
This utility class maintains the current tenant identifier using a ThreadLocal, allowing tenant-specific data to be safely stored and accessed per request thread; the set method assigns the tenant for the current execution, get retrieves it wherever needed (for example, in filters or repositories), and clear removes the value to prevent tenant data from leaking between requests.
2.3 Enforcing Tenant Resolution with a Request Filter
// TenantFilter.java
package com.example.multitenant.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.example.multitenant.util.TenantContext;
import java.io.IOException;
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String tenant = request.getHeader("X-Tenant-ID");
if (tenant == null) {
throw new RuntimeException("Missing X-Tenant-ID header");
}
TenantContext.set(tenant);
try {
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
This servlet filter runs once per request and resolves the tenant identifier from the X-Tenant-ID HTTP header, enforcing its presence before request processing continues; the tenant value is stored in TenantContext for downstream access during the request lifecycle and is reliably cleared in a finally block to avoid cross-request tenant leakage.
2.4 Designing Tenant-Aware OAuth Client Storage
// OAuthClientEntity.java
package com.example.multitenant.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "oauth_client")
public class OAuthClientEntity {
@Id
private String id;
private String tenantId;
private String clientId;
private String clientSecret;
private String scopes;
}
This JPA entity represents an OAuth client persisted in the database, where the oauth_client table stores client-specific credentials and configuration; the tenantId field enables tenant-level isolation, while clientId, clientSecret, and scopes define the OAuth client’s identity, authentication secret, and authorized permissions respectively.
2.5 Persisting and Resolving OAuth Clients per Tenant
// OAuthClientJpaRepository.java
package com.example.multitenant.repository;
import com.example.multitenant.model.OAuthClientEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OAuthClientJpaRepository
extends JpaRepository<OAuthClientEntity, String> {
Optional<OAuthClientEntity>
findByClientIdAndTenantId(String clientId, String tenantId);
}
This Spring Data JPA repository provides database access for OAuth client entities, extending JpaRepository to inherit standard CRUD operations while adding a custom query method that resolves a client by both clientId and tenantId, ensuring OAuth clients are uniquely identified and securely isolated per tenant.
2.6 Making RegisteredClientRepository Tenant-Aware
// TenantRegisteredClientRepository.java
package com.example.multitenant.service;
import com.example.multitenant.repository.OAuthClientJpaRepository;
import com.example.multitenant.model.OAuthClientEntity;
import com.example.multitenant.util.TenantContext;
import org.springframework.stereotype.Component;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
@Component
public class TenantRegisteredClientRepository implements RegisteredClientRepository {
private final OAuthClientJpaRepository repo;
public TenantRegisteredClientRepository(OAuthClientJpaRepository repo) {
this.repo = repo;
}
@Override
public RegisteredClient findByClientId(String clientId) {
String tenant = TenantContext.get();
return repo.findByClientIdAndTenantId(clientId, tenant)
.map(this::toRegisteredClient)
.orElse(null);
}
private RegisteredClient toRegisteredClient(OAuthClientEntity e) {
return RegisteredClient.withId(e.getId())
.clientId(e.getClientId())
.clientSecret(e.getClientSecret())
.scope(e.getScopes())
.authorizationGrantType(
AuthorizationGrantType.CLIENT_CREDENTIALS)
.build();
}
}
This component adapts Spring Authorization Server’s RegisteredClientRepository to be tenant-aware by resolving OAuth clients based on the current tenant stored in TenantContext; when a client lookup occurs, it combines the incoming clientId with the tenant identifier to fetch the correct record from the database and converts the persisted entity into a RegisteredClient, ensuring OAuth client configuration is properly isolated and resolved per tenant.
2.7 Propagating Tenant Context into JWT Access Tokens
// OAuth2TokenCustomizer.java
package com.example.multitenant.config;
import com.example.multitenant.util.TenantContext;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
context.getClaims()
.claim("tenant_id", TenantContext.get());
};
}
This token customizer injects the current tenant identifier into the issued JWT by adding a tenant_id claim at token-encoding time, allowing downstream resource servers and services to reliably identify and enforce tenant-specific authorization rules based on the tenant context embedded directly within the access token.
Note: For brevity, only the classes that are directly relevant to implementing multitenant behavior in the Authorization Server are shown in this section. Supporting or non-essential components such as full security configuration, password encoders, user detail services, exception handlers, logging configuration, and application bootstrap classes are intentionally omitted, as they do not materially affect the multitenancy design.
3. Securing a Multitenant Resource Server
3.1 Modeling Tenant-Owned Domain Data
// Product.java
package com.example.multitenant.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Table;
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue
private Long id;
private String tenantId;
private String name;
}
This JPA entity models a tenant-owned domain object, where each Product record is associated with a specific tenant via the tenantId field, enabling logical data isolation in a shared database while the generated id serves as the primary key and name represents the product’s business attribute.
3.2 Tenant-Scoped Data Access with JPA Repositories
// ProductRepository.java
package com.example.multitenant.repository;
import com.example.multitenant.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository
extends JpaRepository<Product, Long> {
List<Product> findByTenantId(String tenantId);
}
This repository defines data access for the protected resource domain, extending JpaRepository for standard persistence operations while providing a tenant-aware query method that retrieves products filtered by tenantId, ensuring each API request only accesses data belonging to the authenticated tenant.
3.3 Enforcing Tenant Isolation at the API Layer
// ProductController.java
package com.example.multitenant.controller;
import com.example.multitenant.model.Product;
import com.example.multitenant.repository.ProductRepository;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository repo;
public ProductController(ProductRepository repo) {
this.repo = repo;
}
@GetMapping
public List<Product> products(
@AuthenticationPrincipal Jwt jwt) {
String tenantId = jwt.getClaim("tenant_id");
return repo.findByTenantId(tenantId);
}
}
This REST controller exposes a protected API endpoint that derives the tenant context directly from the authenticated JWT, extracting the tenant_id claim via @AuthenticationPrincipal; the tenant value is then used to query the repository so that each request only returns products belonging to the tenant encoded in the access token, enforcing tenant isolation at the API layer.
3.4 Configuring the Resource Server for JWT-Based Authorization
// ResourceServerSecurityConfig.java
package com.example.multitenant.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ResourceServerSecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth ->
auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 ->
oauth2.jwt());
return http.build();
}
}
This security configuration secures all /api/** endpoints by configuring the application as an OAuth2 resource server, requiring every request to be authenticated and validated using JWT-based access tokens, thereby ensuring that only properly issued tokens (including tenant-aware claims) can access the protected API resources.
Note: For brevity, this section highlights only the core components required to enforce tenant isolation within the Resource Server. Supporting or non-essential classes such as advanced security configuration, exception handling, validation, logging, and application bootstrap code are intentionally omitted, as they do not materially impact the multitenant authorization model.
4. Tenant-Aware Authorization Flow
4.1 Client Request Initiation (Authorization Server – localhost:9000)
An OAuth2 client belonging to a specific tenant initiates an authorization flow against the Spring Authorization Server, which is running locally on port 9000. Along with standard OAuth2 parameters (client_id, client_secret, grant_type), the request includes the tenant identifier via the X-Tenant-ID HTTP header.
curl -X POST "http://localhost:9000/oauth2/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "X-Tenant-ID: tenant_123" \ -u my-client:secret123 \ -d "grant_type=client_credentials&scope=read write"
4.2 Tenant Context Resolution (Authorization Server – localhost:9000)
The request is intercepted by the TenantFilter running inside the Authorization Server application on localhost:9000. The filter extracts the tenant identifier from the X-Tenant-ID header and stores it in TenantContext.
// TenantFilter extracts tenant ID and sets in TenantContext
TenantContext.set("tenant_123");
4.3 Tenant-Scoped Client Validation (Authorization Server – localhost:9000)
The Authorization Server validates the OAuth2 client using the tenant-aware RegisteredClientRepository. Client resolution is performed using both the client_id and the tenant ID currently stored in TenantContext.
repo.findByClientIdAndTenantId("my-client", "tenant_123")
.orElseThrow(() ->
new OAuth2AuthenticationException("Invalid client for tenant"));
Only clients registered under tenant_123 are accepted. Clients belonging to other tenants are rejected even if the client_id matches.
4.4 User Authentication and Consent (Authorization Server – localhost:9000)
For flows involving users (such as authorization code or refresh token flows), user authentication occurs entirely within the Authorization Server running on localhost:9000.
- Users are authenticated against tenant-specific identity stores
- Consent screens, if required, are displayed per tenant
- Consent decisions are persisted with tenant awareness
This guarantees that user identities and consents are isolated per tenant.
4.5 Tenant-Aware Token Issuance (Authorization Server – localhost:9000)
After successful client (and optional user) authentication, the Authorization Server issues OAuth2 tokens. During JWT encoding, the tenant identifier is injected as a custom claim using OAuth2TokenCustomizer.
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write",
"tenant_id": "tenant_123"
}
The JWT is issued and signed by the Authorization Server on localhost:9000.
4.6 Calling Protected APIs (Resource Server – localhost:8081)
The client uses the issued access token to call a protected API exposed by the Resource Server, which is running locally on port 8081.
curl -X GET "http://localhost:8081/api/products" \ -H "Authorization: Bearer <ACCESS_TOKEN>"
The Resource Server performs the following actions:
- Validates the JWT signature
- Verifies token expiration and scopes
- Extracts the
tenant_idclaim from the token
4.7 Tenant-Isolated Data Access (Resource Server – localhost:8081)
Inside the Resource Server application, the controller extracts the tenant identifier from the JWT and uses it to scope all data access operations.
String tenantId = jwt.getClaim("tenant_id");
return repo.findByTenantId(tenantId);
Only data belonging to tenant_123 is returned, even though the database schema is shared across tenants.
Response:
[
{
"id": 101,
"tenantId": "tenant_123",
"name": "Premium Widget"
},
{
"id": 102,
"tenantId": "tenant_123",
"name": "Advanced Gadget"
}
]
5. Conclusion
Spring Authorization Server can be made fully multitenant by introducing tenant resolution, making SAS repositories tenant-aware, and including tenant context in tokens. This approach enables scalable, secure SaaS identity platforms while keeping a single authorization server deployment.




