Token-Level Tenant Isolation: Custom Claims and the Confused Deputy
Token-Level Tenant Isolation
The Assumption
Multi-tenant systems determine the current tenant from the request: a subdomain, a path parameter, or a header. The assumption: the authenticated user belongs to whichever tenant the request claims.
This is a confused deputy attack. The server trusts the client to identify the tenant correctly. A user authenticated as Tenant A changes the tenant identifier in their request to Tenant B. If the server uses the request-provided tenant instead of the token-embedded tenant, tenant isolation fails silently.
The Attack
Cross-tenant data access via path parameter manipulation.
Alice is authenticated with a valid JWT. Her token’s tenant_id claim is "acme-corp". She discovers that the API uses path-based tenant routing:
GET /api/tenants/acme-corp/projects → 200 (her projects)
GET /api/tenants/globex-inc/projects → 200 (Globex's projects!)
GET /api/tenants/initech/billing → 200 (Initech's billing data!)
The API trusts the path parameter for tenant context. Alice’s token is valid (signature checks pass, expiry is in the future), so every request succeeds. She iterates through tenant slugs and exfiltrates data from every tenant on the platform.
This is not an authorization bypass. It is the absence of authorization. The server checks “is Alice authenticated?” but never checks “is Alice authorized for this tenant?”
Tenant header injection. Some architectures use a X-Tenant-ID header. An attacker modifies the header in their request. The server extracts the tenant from the header and serves data accordingly. The header is not cryptographically bound to the user’s identity. Any authenticated user can set any tenant header value.
The Spec or Mechanism
The defense: the tenant context comes from the signed JWT, not from the request. The authorization server embeds tenant_id in the token during issuance (configured in CH4 via OAuth2TokenCustomizer). The resource server extracts tenant_id from the validated JWT and uses it as the authoritative tenant context.
The enforcement chain:
- JWT is validated (signature, expiry, issuer, audience).
tenant_idclaim is extracted from the validated JWT.- A
TenantContextis set for the current request. - A global filter verifies that any tenant identifier in the request path or body matches the token’s tenant_id.
- If they do not match: 403 Forbidden.
- If they match (or the endpoint is not tenant-scoped): request proceeds.
The Implementation
TenantAwareAuthentication
public class TenantAwareAuthentication extends AbstractAuthenticationToken {
private final Jwt jwt;
private final String tenantId;
private final String tenantRole;
public TenantAwareAuthentication(Jwt jwt, Collection<GrantedAuthority> authorities) {
super(authorities);
this.jwt = jwt;
this.tenantId = jwt.getClaimAsString("tenant_id");
this.tenantRole = jwt.getClaimAsString("tenant_role");
setAuthenticated(true);
if (this.tenantId == null || this.tenantId.isBlank()) {
throw new InsufficientAuthenticationException(
"Token does not contain tenant_id claim");
}
}
@Override
public Object getCredentials() {
return jwt.getTokenValue();
}
@Override
public Object getPrincipal() {
return jwt.getSubject();
}
public String getTenantId() {
return tenantId;
}
public String getTenantRole() {
return tenantRole;
}
}
Custom JwtAuthenticationConverter
@Component
public class TenantAwareJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final JwtGrantedAuthoritiesConverter authoritiesConverter;
public TenantAwareJwtConverter() {
this.authoritiesConverter = new JwtGrantedAuthoritiesConverter();
}
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>(
authoritiesConverter.convert(jwt));
// Add tenant role as a granted authority
String tenantRole = jwt.getClaimAsString("tenant_role");
if (tenantRole != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + tenantRole));
}
// Add fine-grained permissions
List<String> permissions = jwt.getClaimAsStringList("permissions");
if (permissions != null) {
permissions.stream()
.map(SimpleGrantedAuthority::new)
.forEach(authorities::add);
}
return new TenantAwareAuthentication(jwt, authorities);
}
}
Tenant Enforcement Filter
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 100) // After authentication, before controllers
public class TenantEnforcementFilter extends OncePerRequestFilter {
private static final Pattern TENANT_PATH_PATTERN =
Pattern.compile("/api/tenants/([^/]+)/.*");
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws Exception {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof TenantAwareAuthentication tenantAuth) {
String pathTenant = extractTenantFromPath(request.getRequestURI());
if (pathTenant != null && !pathTenant.equals(tenantAuth.getTenantId())) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\":\"tenant_mismatch\"," +
"\"message\":\"Token tenant does not match requested resource\"}");
return;
}
// Set tenant context for downstream use
TenantContext.setCurrent(tenantAuth.getTenantId());
}
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
private String extractTenantFromPath(String uri) {
Matcher matcher = TENANT_PATH_PATTERN.matcher(uri);
if (matcher.matches()) {
return matcher.group(1);
}
return null;
}
}
TenantContext (ThreadLocal)
public final class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {}
public static void setCurrent(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getCurrent() {
String tenant = CURRENT_TENANT.get();
if (tenant == null) {
throw new IllegalStateException(
"No tenant context set. This is a programming error.");
}
return tenant;
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
SecurityFilterChain Configuration
// VULNERABLE: No tenant enforcement, trusts path parameter
@Bean
public SecurityFilterChain vulnerableChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated() // Auth only, no tenant check
)
.build();
}
// HARDENED: Tenant-aware authentication with enforcement filter
@Bean
public SecurityFilterChain hardenedChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(tenantAwareJwtConverter()))
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/tenants/*/admin/**").hasRole("ADMIN")
.requestMatchers("/api/tenants/**").authenticated()
.anyRequest().denyAll()
)
.addFilterAfter(tenantEnforcementFilter(),
BearerTokenAuthenticationFilter.class)
.build();
}
Multi-Tenant Admin Access
Some users legitimately access multiple tenants (platform administrators, support staff). Model this with a special claim:
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
UserTenantService userTenantService) {
return context -> {
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
String username = context.getPrincipal().getName();
TenantMembership membership = userTenantService.getActiveMembership(username);
context.getClaims().claims(claims -> {
claims.put("tenant_id", membership.tenantId());
claims.put("tenant_role", membership.role().name());
// Platform admins get a wildcard tenant access indicator
if (membership.isPlatformAdmin()) {
claims.put("tenant_scope", "*");
}
});
}
};
}
The enforcement filter checks for the wildcard:
// In TenantEnforcementFilter
if (pathTenant != null && !pathTenant.equals(tenantAuth.getTenantId())) {
// Check for platform admin wildcard
String tenantScope = tenantAuth.getJwt().getClaimAsString("tenant_scope");
if (!"*".equals(tenantScope)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// ...
return;
}
// Platform admin: set context to the requested tenant
TenantContext.setCurrent(pathTenant);
} else {
TenantContext.setCurrent(tenantAuth.getTenantId());
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class TenantIsolationTest {
@Autowired
private MockMvc mockMvc;
@Test
void userCanAccessOwnTenantResources() throws Exception {
Jwt jwt = tenantJwt("alice", "acme-corp", "USER");
mockMvc.perform(get("/api/tenants/acme-corp/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isOk());
}
@Test
void userCannotAccessOtherTenantResources() throws Exception {
Jwt jwt = tenantJwt("alice", "acme-corp", "USER");
mockMvc.perform(get("/api/tenants/globex-inc/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("tenant_mismatch"));
}
@Test
void tokenWithoutTenantClaimIsRejected() throws Exception {
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "alice")
// No tenant_id claim
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
mockMvc.perform(get("/api/tenants/acme-corp/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isUnauthorized());
}
@Test
void platformAdminCanAccessAnyTenant() throws Exception {
Jwt jwt = Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", "superadmin")
.claim("tenant_id", "platform")
.claim("tenant_scope", "*")
.claim("tenant_role", "ADMIN")
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
mockMvc.perform(get("/api/tenants/globex-inc/projects")
.with(jwt().jwt(jwt)))
.andExpect(status().isOk());
}
@Test
void tenantMismatchIsLoggedForSecurityAudit() throws Exception {
Jwt jwt = tenantJwt("mallory", "acme-corp", "USER");
mockMvc.perform(get("/api/tenants/globex-inc/billing")
.with(jwt().jwt(jwt)))
.andExpect(status().isForbidden());
// Verify audit log contains the cross-tenant attempt
// (Checked via log appender or audit service in real implementation)
}
private Jwt tenantJwt(String sub, String tenantId, String role) {
return Jwt.withTokenValue("test")
.header("alg", "RS256")
.claim("sub", sub)
.claim("tenant_id", tenantId)
.claim("tenant_role", role)
.claim("aud", List.of("core-api"))
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
}
}
The second test is the critical invariant: a valid, unexpired, correctly signed token for one tenant is rejected when used to access another tenant’s resources. This test must never be removed, disabled, or ignored. It is the tenant isolation regression test.