Las aplicaciones multi-tenant sirven a varios clientes desde la misma instancia, manteniendo los datos y las identidades aisladas. La mayor complejidad está en la autenticación, donde cada tenant suele tener su propio IdP, su propio realm de usuarios y sus propias políticas.
Spring Security 7.x ofrece la abstracción AuthenticationManagerResolver precisamente para este escenario. La idea es resolver, en cada petición, qué AuthenticationManager debe procesarla.
Modelos de tenancy
Antes de elegir la estrategia, hay que decidir el modelo de aislamiento.
- 1. Database per tenant: cada cliente tiene su propia BD. Máximo aislamiento, máximo coste operativo.
- 2. Schema per tenant: una BD compartida con un esquema por cliente. Buen compromiso.
- 3. Shared schema con discriminator: una sola tabla con columna
tenant_id. Eficiente pero exige mucha disciplina para no filtrar datos.
La autenticación suele acompañar al modelo de datos. Si cada tenant tiene su BD, también tendrá su UserDetailsService y su AuthenticationProvider.
Identificar el tenant en cada petición
Existen tres patrones canónicos para extraer el tenantId.
- Subdominio:
cliente1.app.com,cliente2.app.com. Espera mucho cuidado con cookies y CORS. - Header HTTP:
X-Tenant-ID: cliente1. Cómodo en APIs internas. - Claim del JWT: el
tid(tenant id) del token, emitido por el IdP. Patrón habitual en SaaS multi-tenant moderno.
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
public static void set(String tenantId) { CURRENT.set(tenantId); }
public static String get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}
Y un filtro que lee el subdominio y lo guarda en el ThreadLocal.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantResolverFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String host = req.getServerName();
String tenant = host.split("\\.")[0];
TenantContext.set(tenant);
try {
chain.doFilter(req, res);
} finally {
TenantContext.clear();
}
}
}
El
try/finallyconclear()es crítico. En Tomcat los hilos se reutilizan; unThreadLocalno limpiado contaminará la siguiente petición.
AuthenticationManagerResolver
AuthenticationManagerResolver<C> devuelve un AuthenticationManager a partir de un contexto C. La especialización para servlets es AuthenticationManagerResolver<HttpServletRequest>.
@Bean
AuthenticationManagerResolver<HttpServletRequest> tenantResolver(
TenantConfigService config,
PasswordEncoder encoder) {
Map<String, AuthenticationManager> managers = new ConcurrentHashMap<>();
return request -> {
String tenant = TenantContext.get();
return managers.computeIfAbsent(tenant, t -> {
UserDetailsService uds = config.userDetailsServiceFor(t);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
});
};
}
Cada tenant nuevo construye su AuthenticationManager la primera vez que aparece y queda cacheado.
Resolver para Resource Server JWT
En APIs REST con OAuth2, lo habitual es resolver el JwtDecoder por issuer del token. Spring Security ofrece JwtIssuerAuthenticationManagerResolver directamente.
@Bean
SecurityFilterChain api(HttpSecurity http,
TenantConfigService config) throws Exception {
Map<String, AuthenticationManager> managers = config.tenants()
.stream()
.collect(Collectors.toMap(
Tenant::issuerUri,
t -> {
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(
JwtDecoders.fromIssuerLocation(t.issuerUri()));
return new ProviderManager(provider);
}
));
JwtIssuerAuthenticationManagerResolver resolver =
new JwtIssuerAuthenticationManagerResolver(managers::get);
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.authenticationManagerResolver(resolver));
return http.build();
}
Cada tenant tiene su propio Authorization Server (Keycloak realm, Auth0 tenant, Entra ID tenant). El iss del JWT identifica qué JwtDecoder validará la firma.
SecurityContextHolderStrategy por tenant
Por defecto, SecurityContextHolder usa una estrategia ThreadLocal global. Cuando combinamos múltiples tenants en hilos compartidos (@Async, WebClient), conviene cambiar a la estrategia MODE_INHERITABLETHREADLOCAL o configurar manualmente el contexto en cada salto de hilo.
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
Para @Async específicamente, usar DelegatingSecurityContextAsyncTaskExecutor.
@Bean
ThreadPoolTaskExecutor tenantAsyncExecutor() {
ThreadPoolTaskExecutor base = new ThreadPoolTaskExecutor();
base.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(base) {
@Override
public void execute(Runnable task) {
String tenant = TenantContext.get();
super.execute(() -> {
TenantContext.set(tenant);
try {
task.run();
} finally {
TenantContext.clear();
}
});
}
};
}
Aislamiento de datos: Hibernate con tenant filter
A nivel de capa de persistencia, conviene reforzar el aislamiento con un Hibernate filter o con MultiTenantConnectionProvider.
Filtro automático sobre todas las queries.
@Entity
@FilterDef(name = "tenant", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenant", condition = "tenant_id = :tenantId")
public class Pedido { ... }
Y un Aspect que lo activa por petición leyendo TenantContext.
@Aspect
@Component
public class TenantFilterAspect {
@PersistenceContext EntityManager em;
@Before("execution(* com.empresa.repo..*(..))")
public void enable() {
Session session = em.unwrap(Session.class);
session.enableFilter("tenant").setParameter("tenantId", TenantContext.get());
}
}
Esto es defensa en profundidad. La autorización en Spring Security ya impide acceder a datos de otros tenants, pero un bug en el WHERE puede colarse. El filtro de Hibernate es la red de seguridad.
Onboarding de nuevos tenants en caliente
Un sistema multi-tenant maduro permite añadir clientes sin redeploy. El AuthenticationManagerResolver cachea el manager por tenant; cuando aparece uno nuevo, se construye on-the-fly leyendo la configuración de la BD central.
@Service
public class TenantConfigService {
private final TenantRepository repo;
public List<Tenant> tenants() { return repo.findAll(); }
public UserDetailsService userDetailsServiceFor(String tenantId) {
DataSource ds = dataSourceFor(tenantId);
return new JdbcUserDetailsManager(ds);
}
public DataSource dataSourceFor(String tenantId) {
Tenant t = repo.findById(tenantId).orElseThrow();
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(t.jdbcUrl());
cfg.setUsername(t.username());
cfg.setPassword(t.password());
return new HikariDataSource(cfg);
}
}
Cuando se crea un tenant en el panel de administración, se inserta una fila en la tabla tenants y la siguiente petición ya lo encuentra.
Auditoría con tenant en el SecurityContext
Para que los logs y eventos de auditoría incluyan siempre el tenant, lo añadimos como detail del Authentication.
public class TenantAuthenticationDetails extends WebAuthenticationDetails {
private final String tenantId;
public TenantAuthenticationDetails(HttpServletRequest req) {
super(req);
this.tenantId = TenantContext.get();
}
public String getTenantId() { return tenantId; }
}
Y un AuthenticationDetailsSource que lo construye:
.formLogin(form -> form
.authenticationDetailsSource(TenantAuthenticationDetails::new))
A partir de ahí, ((TenantAuthenticationDetails) auth.getDetails()).getTenantId() está disponible en cualquier punto.
Errores frecuentes
- No limpiar el
ThreadLocal: produce cross-tenant leakage en cuanto el hilo se reutiliza. - Cachear
UserDetailsServicepara todos los tenants: si un tenant se elimina, el cache mantiene su servicio activo. Implementa invalidación. - No validar el subdominio en el certificado TLS: si todos los tenants comparten certificado wildcard, asegúrate de que el host validado coincide con el tenant esperado.
- Compartir secret de firma de JWT entre tenants: cada tenant debe tener su propio JWK set para que un compromiso no afecte al resto.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Security
Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Implementar autenticación multi-tenant en Spring Security con un AuthenticationManagerResolver que selecciona el provider según el tenant y aislando el SecurityContext por tenant.