Web

The Arconia Multitenancy Web module adds HTTP-specific multitenancy support for Spring MVC applications. It provides tenant resolution from HTTP headers or cookies, a servlet filter for automatic context management, and a controller annotation for convenient access to the current tenant.

Dependencies

Add the Arconia Multitenancy Web Spring Boot Starter dependency to your project.

  • Gradle

  • Maven

dependencies {
    implementation 'io.arconia:arconia-multitenancy-web-spring-boot-starter'
}
<dependency>
    <groupId>io.arconia</groupId>
    <artifactId>arconia-multitenancy-web-spring-boot-starter</artifactId>
</dependency>

The web starter includes the core multitenancy module, so all context, resolution, tenant details, and observability features are available automatically.

HTTP Tenant Resolution

The web module resolves the tenant identifier from each incoming HTTP request using a configurable strategy.

Header-Based Resolution

By default, the tenant identifier is resolved from the X-TenantId HTTP header.

  • HTTPie

  • curl

http :8080/api/resource X-TenantId:acme
curl -H "X-TenantId: acme" http://localhost:8080/api/resource

You can customize the header name:

arconia:
  multitenancy:
    resolution:
      http:
        header:
          header-name: X-Custom-Tenant

Alternatively, you can resolve the tenant from an HTTP cookie by switching the resolution mode.

arconia:
  multitenancy:
    resolution:
      http:
        resolution-mode: cookie

By default, the cookie name is TENANT-ID. You can customize it:

arconia:
  multitenancy:
    resolution:
      http:
        resolution-mode: cookie
        cookie:
          cookie-name: MY-TENANT

OAuth2-Based Resolution

When the application is secured with OAuth2, the tenant can be resolved from a claim in the token that authenticated the request.

arconia:
  multitenancy:
    resolution:
      http:
        resolution-mode: oauth2

This works both for OAuth2 client applications, where the claim is read from the ID token and the UserInfo response, and for OAuth2 resource servers, where it is read from the access token, whether it is a JWT or an opaque token resolved via introspection. Because the claim is read from the authenticated principal rather than from the raw Authorization header, it also works for client applications that carry a session instead of a bearer token.

It requires Spring Security OAuth2 on the classpath, for example via the spring-boot-starter-security-oauth2-resource-server or spring-boot-starter-security-oauth2-client dependency.

By default, the claim name is tenant_id. You can customize it:

arconia:
  multitenancy:
    resolution:
      http:
        resolution-mode: oauth2
        oauth2:
          claim-name: tid

This mode requires the request to be authenticated before the tenant is resolved, so the TenantContextFilter must run after the Spring Security filter chain. That is the default. See Spring Security Integration if you change the filter placement.

Configuration Properties

Table 1. HTTP Tenant Resolution Configuration Properties
Property Default Description

arconia.multitenancy.resolution.http.enabled

true

Whether HTTP tenant resolution is enabled.

arconia.multitenancy.resolution.http.resolution-mode

header

Mode of HTTP resolution. Options: header, cookie, oauth2.

arconia.multitenancy.resolution.http.header.header-name

X-TenantId

Name of the HTTP header from which to resolve the current tenant.

arconia.multitenancy.resolution.http.cookie.cookie-name

TENANT-ID

Name of the HTTP cookie from which to resolve the current tenant.

arconia.multitenancy.resolution.http.oauth2.claim-name

tenant_id

Name of the OAuth2 token claim from which to resolve the current tenant.

Tenant Context Filter

The TenantContextFilter is a servlet filter that intercepts incoming HTTP requests, resolves the tenant, validates the identifier via a TenantIdentifierValidator, optionally verifies it via a TenantVerifier, and establishes the tenant context for the duration of the request using a ScopedValue binding. It publishes TenantContextAttachedEvent on entry and TenantContextClosedEvent on completion, triggering all registered event listeners.

If the tenant identifier cannot be resolved (e.g., the header is missing), fails validation (e.g., it contains characters outside the accepted set), or fails verification (e.g., the tenant is not found or disabled), the filter returns an HTTP 400 Bad Request response with an application/problem+json body describing the error.

If the tenant registry itself is unavailable, for example because the database backing the TenantDetailsService cannot be reached, the filter returns an HTTP 503 Service Unavailable response instead. That distinguishes a caller sending a bad tenant identifier from an outage on the server side, and keeps a registry failure from surfacing as an unhandled error on every request.

The filter also runs on error dispatches, so error handling for a failed request executes with the same tenant bound as the request that failed. On an error dispatch the filter never writes a response of its own: if the tenant cannot be resolved or validated there, the request proceeds without a tenant context rather than replacing the response the container is already producing.

Asynchronous request handling is a separate case. A Callable or DeferredResult runs on another thread, which does not inherit the ScopedValue binding, and the response is completed on a dispatch where the filter does not re-bind the tenant. See Context Propagation Across Threads.

Filter Order

The TenantContextFilter is auto-configured as a standard servlet filter, registered after the Spring Security filter chain. That is what the oauth2 resolution mode requires, since the tenant claim can only be read once the request has been authenticated.

You can change where the filter runs relative to the other servlet filters:

arconia:
  multitenancy:
    resolution:
      http:
        filter:
          order: -101

Spring Security registers its own filter chain at order -100, so a lower value makes the tenant context available to it. Be aware that the tenant is then resolved for every request reaching the filter, including the login and OAuth2 redirect endpoints. Those are part of the ignored paths by default, so authentication keeps working, but any other endpoint that must be reachable before a tenant is known has to be added to the ignored paths as well.

Spring Security Integration

When the tenant context must be available during security processing, for example for tenant-aware authorization, register the filter inside the security filter chain instead. Place it after the AnonymousAuthenticationFilter, which runs after every authentication filter and before the AuthorizationFilter, so that both the authenticated principal and the tenant context are available where they are needed:

import io.arconia.multitenancy.web.context.filters.TenantContextFilter;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, TenantContextFilter tenantContextFilter) throws Exception {
    return http
        .addFilterAfter(tenantContextFilter, AnonymousAuthenticationFilter.class)
        // ... other security configuration
        .build();
}

@Bean
FilterRegistrationBean<TenantContextFilter> tenantContextFilterRegistration(TenantContextFilter filter) {
    var registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

The second bean disables the registration that Spring Boot would otherwise create for the filter bean, so the filter is applied only by the security filter chain. Without it, the filter would also be registered as a standard servlet filter. That does not run it twice, since TenantContextFilter is a OncePerRequestFilter, but it does mean requests not matched by any SecurityFilterChain would be handled at a different point in the request lifecycle, with no authenticated principal available. The arconia.multitenancy.resolution.http.filter.order property no longer applies once the filter is placed in the security filter chain.

If you need the tenant before authentication, for example to select which authorization server validates the token, use the header or cookie resolution mode and place the filter early in the chain. Such a tenant identifier comes from unverified request data: it is safe to use it to select a trusted issuer, because a token minted for another tenant will not validate against the selected keys, but it must never be trusted as the tenant identity on its own. Always resolve it through a TenantDetailsService so that only known tenants are accepted, and never use it to build an issuer URL directly.

Ignoring Paths

Certain paths can be excluded from tenant resolution. By default, the following paths are ignored:

  • /actuator/**

  • /webjars/**

  • /css/**

  • /js/**

  • /*/.ico

  • /login

  • /oauth2/authorization/**

  • /login/oauth2/code/**

The login and OAuth2 endpoints are ignored so that a user can still authenticate when no tenant has been established yet. At the default filter order those endpoints are handled inside the Spring Security filter chain and never reach the tenant filter, but they do reach it when the login page is served by your own controller, or when the filter is ordered ahead of the security chain.

Patterns are matched against the path within the application, so they keep working when the application is deployed under a context path. With server.servlet.context-path=/app, the /actuator/** pattern still matches a request to /app/actuator/health.

You can add additional paths to ignore without replacing the defaults:

arconia:
  multitenancy:
    resolution:
      http:
        filter:
          additional-ignore-paths:
            - /health
            - /public/**

You can also replace the default ignored paths entirely:

arconia:
  multitenancy:
    resolution:
      http:
        filter:
          ignore-paths:
            - /actuator/**
            - /public/**

Disabling the Filter

You can disable the tenant context filter while keeping the rest of the multitenancy configuration active:

arconia:
  multitenancy:
    resolution:
      http:
        filter:
          enabled: false

Configuration Properties

Table 2. Tenant Context Filter Configuration Properties
Property Default Description

arconia.multitenancy.resolution.http.filter.enabled

true

Whether the HTTP filter resolving the current tenant is enabled.

arconia.multitenancy.resolution.http.filter.order

2147483647

Order of the HTTP filter resolving the current tenant. By default, the filter runs after the Spring Security filter chain, which is required when resolving the tenant from an OAuth2 token.

arconia.multitenancy.resolution.http.filter.ignore-paths

/actuator/*, /webjars/*, /css/, /js/, //.ico, /login, /oauth2/authorization/, /login/oauth2/code/*

HTTP request paths for which tenant resolution will not be performed.

arconia.multitenancy.resolution.http.filter.additional-ignore-paths

[]

Additional HTTP request paths for which tenant resolution will not be performed.

@TenantIdentifier Annotation

In Spring MVC controllers, you can inject the current tenant identifier directly into handler method parameters using the @TenantIdentifier annotation.

import io.arconia.multitenancy.web.context.annotations.TenantIdentifier;

@RestController
class TenantController {

    @GetMapping("/tenant")
    String currentTenant(@TenantIdentifier String tenantId) {
        return tenantId;
    }

}

The annotation resolves the value from the TenantContext and supports String and Optional<String> parameters.

A String parameter is null when no tenant is bound, which happens on an ignored path or when the filter is disabled. This matches how Spring Security’s @AuthenticationPrincipal behaves for an unauthenticated request. Declare the parameter as Optional<String> on handler methods that can be reached both with and without a tenant, so that the absence is visible in the signature:

@GetMapping("/status")
String status(@TenantIdentifier Optional<String> tenantId) {
    return tenantId.map("Serving tenant "::concat).orElse("Serving no particular tenant");
}
You can also use the TenantContext directly to access the tenant identifier from any component, not just controllers. The @TenantIdentifier annotation is a convenience for Spring MVC handler methods.

Custom Tenant Resolution

You can implement your own HTTP tenant resolution strategy by providing a custom HttpRequestTenantResolver bean. This replaces the default header or cookie resolver.

import io.arconia.multitenancy.web.context.resolvers.HttpRequestTenantResolver;

@Bean
HttpRequestTenantResolver customTenantResolver() {
    return request -> {
        // Custom logic to extract tenant from the request
        return request.getParameter("tenant");
    };
}

HttpRequestTenantResolver is a specialization of the core TenantResolver<T> interface for HttpServletRequest sources.