Skip to main content
the auth layer

Token-Level Tenant Isolation: Custom Claims and the Confused Deputy

6 min read Chapter 23 of 45

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:

  1. JWT is validated (signature, expiry, issuer, audience).
  2. tenant_id claim is extracted from the validated JWT.
  3. A TenantContext is set for the current request.
  4. A global filter verifies that any tenant identifier in the request path or body matches the token’s tenant_id.
  5. If they do not match: 403 Forbidden.
  6. 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.