Let's get started with a Microservice Architecture with Spring Cloud:
Implement Multitenancy in Spring Authorization Server
Last updated: January 29, 2026
1. Introduction
The Spring Authorization Server started a few years ago as a side project under Spring’s large portfolio. Since then, it has gained traction and, as of version 7, has been incorporated into Spring Security as an official module.
In this tutorial, we’ll explore how to use it in multitenancy scenarios, a feature that can help serve multiple distinct customers on a single server deployment.
2. Quick Recap
We’ve already covered Spring Authorization Server’s – or SAS, for short – basic usage in previous tutorials, but let’s start with a quick recap.
In a nutshell, SAS is a Spring-based library that lets us quickly implement an OpenID Connect/OAuth 2.0-compliant identity provider. The implementation is built on top of the mature Spring Security framework, making it easy to either embed or extend using familiar Spring-related patterns.
With the project’s migration into Spring Security, there were some changes that made it even easier to integrate this library into users’ applications:
- Version alignment with other Spring Security dependencies
- HttpSecurity’s DSL integration
- Single documentation site, improving developer experience
Moreover, Spring Boot 4 also includes new starter modules, which simplify SAS’s usage. We can now have a fully functional OpenID Connect server in a few minutes using start.spring.io, adding the required dependencies, and configuring a few properties.
Warning: as of this writing, SAS version 7.x is limited to servlet-based applications. If we have a reactive web-based SAS application, we can either rewrite it to use the MVC-based APIs or, at least for now, stay on the 1.x version until we’re ready to upgrade.
3. Multitenancy in Spring Authorization Server
Despite the quality-of-life improvements, one of SAS’s available features that does require some code is multitenancy support.
In a standard SAS implementation, we generally register one or more client applications using properties or database-backed stores, which share the same set of keys and, critically, the same issuer.
This means that SAS-generated tokens created for one client may also be considered valid by another client, since they’ll be signed by the same key. Depending on the client’s configuration, this may pose a security risk: since not all clients implement audience validation, a malicious or compromised client may acquire a token and use it to access data intended for other clients.
Using multitenancy, we can segregate clients so that each tenant uses its own key pairs to sign tokens. Even if clients from one tenant are compromised, the tokens won’t be recognized by other tenants.
SAS follows the OpenID Connect recommendation regarding multitenancy. As per the spec, the issuer URL may have a path after the host:port part. In SAS’s implementation, we use the path’s last part as the tenant identifier:
- Issuer URL: https://example.com:9443/issuer1 => Tenant identifier: issuer1
- Issuer URL: https://example.com:9443/issuer2 => Tenant identifier: issuer2
4. Project Setup
Let’s create a simple SAS-based project to see how multitenancy works in practice.
First, let’s add the required Spring Boot starter Maven dependencies to our project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
The latest versions of these dependencies are available on Maven Central:
- spring-boot-starter-security-oauth2-authorization-server
- spring-boot-starter-security-oauth2-authorization-server-test
We also need to enable multitenancy support for our server, which is disabled by default. The easiest way to enable it is by adding this property to our application.yaml (or properties) file:
spring:
security:
oauth2:
authorizationserver:
multiple-issuers-allowed: true
5. Figuring Out the Current Tenant
Just enabling multiple issuers is not enough to fully enable multitenancy. If we carefully check SAS’s core components, they all implement interfaces that are not aware of the current tenant.
For instance, let’s look at the RegisteredClientRepository component, which has three methods:
public interface RegisteredClientRepository {
void save(RegisteredClient registeredClient);
RegisteredClient findById(String id);
RegisteredClient findByClientId(String clientId);
}
Notice that none of them take a tenant identifier or something like that, which could be used by the implementation to find out which tenant the method should use.
So, how should we proceed? The solution lies in the AuthorizationServerContextFilter, an internal class that is always added to the SAS’s SecurityFilterChain.
This filter does two things:
- Firstly, it extracts the issuer identifier from the current request, using the IssuerResolver helper class
- Secondly, it creates an AuthorizationServerContext instance and uses AuthorizationServerContextHolder to bind it to the current thread.
Since this filter is called before other SAS filters, we can retrieve the issuer identifier inside a component whenever we need:
var issuer = AuthorizationServerContextHolder.getContext().getIssuer()
6. Multitenant-aware Components Implementation Strategy
Before we start implementing those components, we must define how we are going to load information for each tenant. For this tutorial, we’ll keep things simple and use a properties/YAML-based approach.
This is the @ConfigurationProperties class we’ll use to read the information of each tenant and store it in a map, indexed by its tenant identifier:
@ConfigurationProperties(prefix = "multitenant-auth-server")
public class MultitenantAuthServerProperties {
private Map<String, OAuth2AuthorizationServerProperties> tenants = new HashMap<>();
public Map<String, OAuth2AuthorizationServerProperties> getTenants() {
return tenants;
}
public void setTenants(Map<String, OAuth2AuthorizationServerProperties> tenants) {
this.tenants = tenants;
}
}
The values in the tenants map use the library’s own OAuth2AuthorizationServerProperties to store tenant-specific information, such as the registered clients.
This is an example of an application.yaml file defining two tenants with one registered client on each. Notice that even if the clients use the same client-id value, they’ll still be treated as distinct clients.
multitenant-auth-server:
tenants:
issuer1:
client:
client1:
require-authorization-consent: false
registration:
client-name: Client 1 - Issuer 1
client-id: client1
scopes:
- openid
- email
- account:read
# ... other properties omitted
issuer2:
client:
client1:
require-authorization-consent: false
registration:
client-name: 'Client 1 - Issuer 2'
client-id: client1
scopes:
- openid
- email
- account:write
# ... other properties omitted
The implementation strategy for the components will follow a composite delegate approach. At initialization time, each component receives a map of instances of the same kind indexed by tenant identifier. In each case, the component will implement the required functionality following a simple strategy:
- Retrieve the current issuer
- Map the issuer to a tenant identifier
- Use the tenant identifier to look up a matching delegate
- Call the appropriate delegate’s method
Since steps 1 to 3 are essentially the same regardless of the component type or called method, we’ll factor them out to a base class:
public class AbstractMultitenantComponent<T> {
private Map<String,T> componentsByTenant;
private Supplier<AuthorizationServerContext> authorizationServerContextSupplier;
protected AbstractMultitenantComponent(Map<String,T> componentsByTenant,
Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
this.componentsByTenant = componentsByTenant;
this.authorizationServerContextSupplier = authorizationServerContextSupplier;
}
protected Optional<T> getComponent() {
var authorizationServerContext = authorizationServerContextSupplier.get();
if (authorizationServerContext == null || authorizationServerContext.getIssuer() == null) {
return Optional.empty();
}
var issuer = authorizationServerContext.getIssuer();
for (var entry : componentsByTenant.entrySet()) {
if (issuer.endsWith(entry.getKey())) {
return Optional.of(entry.getValue());
}
}
return Optional.empty();
}
}
The getComponent() method contains the shared logic used by subclasses to get the tenant-specific component implementation to use with the current request. Notice that we’ve opted to use a Supplier<AuthorizationServerContext> instead of using AuthorizationServerContextHolder directly. The main reason is to simplify unit tests and to shield this logic from the strategy used to get the AuthorizationServerContext instance.
7. Core Components Implementation
Now, we’re ready to add multitenancy support for the following core SAS components:
- RegisteredClientRepository
- OAuth2AuthorizationService
- OAuth2AuthorizationConsentService
- JWKSource<SecurityContext>
Thanks to the functionality provided by the base class, the implementation is trivial. For instance, this is the full source for MultitenantRegisteredClientRepository, our multi-tenant-aware implementation for the RegisteredClientRepository component:
public class MultitenantRegisteredClientRepository
extends AbstractMultitenantComponent<RegisteredClientRepository>
implements RegisteredClientRepository {
public MultitenantRegisteredClientRepository(Map<String, RegisteredClientRepository> clientRepoByTenant,
Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
super(clientRepoByTenant,authorizationServerContextSupplier);
}
@Override
public void save(RegisteredClient registeredClient) {
getComponent()
.orElseThrow(UnknownIssuerException::new)
.save(registeredClient);
}
@Override
public @Nullable RegisteredClient findById(String id) {
return getComponent()
.map(repo -> repo.findById(id))
.orElse(null);
}
@Override
public @Nullable RegisteredClient findByClientId(String clientId) {
return getComponent()
.map(repo -> repo.findByClientId(clientId))
.orElse(null);
}
}
The other implementation classes, available online, follow the same pattern.
8. Configuration
The next task is to register our components as @Bean instances, so SAS can use them instead of the regular ones.
AuthServerConfiguration is a @Configuration class that contains the required logic to build the delegate maps based on the provided properties and instantiate the multi-tenant-aware components.
A good example is the code that creates the OAuth2AuthorizationService bean:
@Bean
OAuth2AuthorizationService multitenantAuthorizationService(Supplier<AuthorizationServerContext> authorizationServerContextSupplier) {
Map<String, OAuth2AuthorizationService> authServiceByTenant = new HashMap<>();
for(var tenantId : multitenantAuthServerProperties.getTenants().keySet()) {
authServiceByTenant.put(tenantId, new InMemoryOAuth2AuthorizationService());
}
return new MultitenantOAuth2AuthorizationService(authServiceByTenant,authorizationServerContextSupplier);
}
Here, we iterate over the tenant identifiers and, for each one, we create an InMemoryOAuth2AuthorizationService instance and put it in a new entry in the delegate map.
Once we’ve built the delegate map, we pass it to the multi-tenant aware component, together with the required Supplier<AuthorizationServerContext>.
9. Testing
Last, but not least, let’s write some tests to validate that we now have a multi-tenant-aware authorization server.
First, let’s check that our discovery endpoint is correctly handling requests that include a tenant identifier in the path:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MultitenantAuthServerApplicationUnitTest {
@LocalServerPort
private int port;
@Test
void whenRequestDiscoveryDocumentForIssuer1_thenSuccess() {
restTestClient.get()
.uri("/issuer1/.well-known/openid-configuration")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.issuer")
.isEqualTo("http://localhost:" + port + "/issuer1");
}
// ... other tests omitted
}
This test starts the application using an ephemeral local port. We use a RestTestClient to make a GET request to the well-known location that returns the OIDC discovery document. Since we’ve added the issuer1 path element before the .well-known part, the returned JSON structure must include it in the issuer property.
Next, let’s simulate a client_credentials token request for issuer1 using a valid client credentials and scope:
@Test
void givenClientCredentialsAndValidScope_whenRequestTokenForIssuer1_thenSuccess() {
var response = restTestClient.post()
.uri("/issuer1/oauth2/token")
.header("Authorization", "Basic " + base64Encode("client1:secret1"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&scope=account:read")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("$.access_token")
.exists()
.returnResult()
.getResponseBodyContent();
assertNotNull(response);
}
Let’s ensure that issuer1 clients can’t use a scope valid only for issuer2:
@Test
void givenClientCredentialsAndinalidScope_whenRequestTokenForIssuer1_thenError() {
restTestClient.post()
.uri("/issuer1/oauth2/token")
.header("Authorization", "Basic " + base64Encode("client1:secret1"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&scope=account:write") // Invalid scope for Tenant1
.exchange()
.expectStatus()
.is4xxClientError();
}
Cool. As expected, we’ve got a 4xx error, which is what we want in this case.
10. Conclusion
In this tutorial, we’ve seen how to implement multitenancy in Spring Authorization Server.
By creating multi-tenant-aware components, we’ve added support for multiple tenants within a server instance.
As always, the full source code is available over on GitHub.















