Web

The Arconia Multitenancy Web module adds HTTP-specific multitenancy support for Spring MVC applications. It resolves the tenant of each request from a header, a cookie, the path, the subdomain, or an OAuth2 token, as described in Tenant Resolution. This page covers the servlet filter that establishes the tenant context for each request, and the controller annotation that gives 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, tenant details, caching, and observability features are available automatically.

Tenant Context Filter

The TenantContextFilter is a servlet filter that intercepts incoming HTTP requests, resolves the tenant with the resolver chain, 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.

Failure Responses

When no tenant context can be established, the filter hands the request to a TenantResolutionFailureHandler together with a TenantResolutionFailure describing the outcome. The default handler writes an application/problem+json response.

Outcome Meaning Status

MISSING

No resolver found a tenant identifier in the request.

400

INVALID

A tenant identifier was present but unusable: it violates the identifier policy, or the request carries conflicting or incomplete tenant information, such as two X-TenantId headers or an OAuth2 token without the tenant claim.

400

REJECTED

The tenant identifier is well-formed, but the tenant verifier rejected it because the tenant is unknown or disabled.

400

UNAVAILABLE

The tenant could not be verified because the tenant registry failed, for example, because the database backing the TenantDetailsService cannot be reached.

503

The detail of the problem response carries the message of the failure and instance carries the request URI. An unknown tenant and a disabled tenant produce the same message on purpose so that callers cannot enumerate which tenants exist. A registry outage is reported as 503 rather than 400, so that a server-side problem is distinguishable from a bad request and does not surface as an unhandled error on every request.

Every rejection is logged at DEBUG level, with the identifier in a log-safe form. A registry outage is logged at WARN level in a single line, since it repeats on every request while it lasts, and with the stack trace when DEBUG is enabled for io.arconia.multitenancy.web.context.TenantContextFilter.

The response is produced by the filter, outside Spring MVC, so @ControllerAdvice and spring.mvc.problemdetails do not apply to it. To shape the response yourself, for example, to redirect a browser to a tenant selection page when the tenant cookie is missing, register a TenantResolutionFailureHandler bean:

import io.arconia.multitenancy.web.resolution.TenantResolutionFailure;
import io.arconia.multitenancy.web.resolution.TenantResolutionFailureHandler;

@Bean
TenantResolutionFailureHandler tenantResolutionFailureHandler() {
    return (request, response, failure) -> {
        if (failure.outcome() == TenantResolutionFailure.Outcome.MISSING) {
            response.sendRedirect("/choose-tenant");
        } else {
            response.sendError(400, failure.message());
        }
    };
}

The handler decides the response only. The filter still decides the outcome, logs it, and never calls the handler on an error dispatch.

Error Dispatches

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. The tenant established for the request is bound again as it is, without resolving it a second time, so the error page gets the right tenant even in the path mode, where the error dispatch has the error page as its request URI. When the request failed before a tenant was established, the filter resolves it on the error dispatch. 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. Spring Boot registers Filter beans for every dispatcher type, and the Spring Security filter chain is registered for every dispatcher type too, so this behaviour is the same whether the filter runs as a servlet filter or inside the security filter chain.

The same applies to the asynchronous dispatch that completes a Callable, DeferredResult, or streaming response: the tenant established for the request is bound again, so return-value handling and @ExceptionHandler methods see it. The Callable body runs on the executor Spring Boot auto-configures, so it gets the tenant through context propagation, whereas a DeferredResult completed from a thread of your own does not.

A resolver, validator, or verifier that throws an unexpected exception other than the ones described above is treated as a programming error, and the exception propagates like any other servlet exception.

Filter Placement

The TenantContextFilter is auto-configured as a standard servlet filter that runs after the Spring Security filter chain. With that placement the tenant is available to your controllers and services, but not to anything that runs inside the security filter chain. Whether that is enough depends on the resolution mode and on what needs the tenant.

Running after Spring Security is the default for two reasons. The oauth2 mode needs an authenticated request. And for every mode, it means that an unauthenticated caller is answered by Spring Security alone: with the tenant filter in front of the security filter chain, an unauthenticated caller could tell a known tenant, answered with 401 by Spring Security, from an unknown or missing one, answered with 400 by the tenant filter, and use the difference to enumerate tenants.

Anything that runs during authentication and reads the tenant context requires the tenant to be bound before the security filter chain runs. Typical examples are an AuthenticationManagerResolver that selects the JWT decoder for the tenant, or a ClientRegistrationRepository that looks up the tenant’s OAuth2 client. With the default placement, such a component calls TenantContext.getRequiredTenantIdentifier() before any tenant is bound, the resulting TenantNotFoundException escapes the security filter chain, and the request fails with an HTTP 500 response. The first visible sign is the tenant filter running on the error dispatch rather than on the request itself. Use the placement described in Header, Cookie, Path and Subdomain Modes for these cases.

In these modes the tenant comes from the request itself, so nothing is gained by waiting for authentication. When authentication needs the tenant, bind it before the security filter chain. The simplest way is to lower the filter order, since Spring Security registers its filter chain at order -100:

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

The filter stays registered as a standard servlet filter, so no further configuration is needed. 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.

With the filter in front of Spring Security, unauthenticated callers can tell known tenants from unknown ones by the 401 or 400 they get back, as explained in Filter Placement. Accept this only when authentication itself needs the tenant, and consider a custom TenantResolutionFailureHandler that answers every failure with the same status Spring Security uses.

Alternatively, place the filter inside the security filter chain, right before the authentication filter that needs it. In an OAuth2 resource server that is the BearerTokenAuthenticationFilter, so that an AuthenticationManagerResolver can select the authorization server for the tenant:

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

import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;

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

In an OAuth2 login client, place it before the OAuth2AuthorizationRequestRedirectFilter instead, so that the tenant is known when the authorization request is built. Either way, also disable the standard servlet registration as described in Registering the Filter in the Security Filter Chain.

A tenant identifier resolved before authentication is unverified request data. Use it only to select a trusted issuer, never as the tenant identity on its own, as described in Trusting the Resolved Tenant.

OAuth2 Mode

In this mode the tenant is read from the authenticated principal, so the filter can only run after authentication. The default placement satisfies that. When the tenant must also be available to authorization rules, place the filter inside the security filter chain after the AnonymousAuthenticationFilter, which runs after every authentication filter and before the AuthorizationFilter:

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

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();
}

Then disable the standard servlet registration as described next.

Registering the Filter in the Security Filter Chain

Spring Boot registers every Filter bean as a standard servlet filter. When the TenantContextFilter is added to a SecurityFilterChain, disable that registration so that the filter is applied only by the security filter chain:

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

import org.springframework.boot.web.servlet.FilterRegistrationBean;

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

Without this bean the filter would not run twice, since TenantContextFilter is a OncePerRequestFilter, but requests not matched by any SecurityFilterChain would be handled by the servlet registration at a different point in the request lifecycle. The arconia.multitenancy.resolution.http.filter.order property does not apply to a filter placed in the security filter chain.

Ignoring Paths

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

  • /actuator/**

  • /livez

  • /readyz

  • /webjars/**

  • /css/**

  • /js/**

  • /*/.ico

  • /login

  • /oauth2/authorization/**

  • /login/oauth2/code/**

CORS preflight requests are skipped as well, wherever they are sent, since browsers send them without the headers and cookies that carry the tenant.

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.

The actuator paths are ignored so that health probes and metrics scrapers need no tenant. The defaults assume Spring Boot’s default management.endpoints.web.base-path, and the /livez and /readyz entries match the additional probe paths Spring Boot registers when management.endpoint.health.probes.add-additional-paths is enabled. Adjust the paths if you change the base path. When the actuator is served on a separate port with management.server.port, the tenant filter is not registered on that port at all, since Spring Boot registers application filters only on the main server, so these entries only matter on a shared port.

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, including the resolver beans, which you can then use from your own filter:

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

To remove the resolvers as well, disable HTTP tenant resolution altogether with arconia.multitenancy.resolution.http.enabled=false.

Configuration Properties

Table 1. 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. Set it below -100 to bind the tenant before the security filter chain. See Filter Placement.

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

/actuator/*, /livez, /readyz, /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.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.