• 22.08.2026 23:12:39
  • Admin Admin

Build a Spring Security resource server that accepts multiple trusted JWT issuers, bounds JWKS network calls, maps issuer-specific claims, and enforces tenant isolation in every Spring REST API query.

Spring Security Multi-Issuer JWT Validation Without Tenant Confusion

Spring Security: Establish an Explicit Issuer Trust Boundary

A multi-tenant API must treat the iss claim as an untrusted routing hint until it matches a local allow-list. A useful spring framework training exercise is to configure one authentication manager per known issuer and select only from that fixed set. Do not create a decoder from an arbitrary issuer read from a bearer token: issuer discovery can otherwise turn your authentication endpoint into an SSRF primitive and an unbounded decoder cache.

@Bean
SecurityFilterChain apiSecurity(HttpSecurity http,
        AuthenticationManagerResolver<HttpServletRequest> issuerResolver) throws Exception {
    return http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/health").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .authenticationManagerResolver(issuerResolver))
        .build();
}

@Bean
AuthenticationManagerResolver<HttpServletRequest> issuerResolver() {
    return JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
        "https://login.example.com/realms/staff",
        "https://id.partner.example.net"
    );
}

Use an integration test that sends a correctly signed token whose issuer is not in the allow-list and assert HTTP 401. Generate test keys with Nimbus JOSE JWT or run an ephemeral Keycloak container through Testcontainers; this catches the common mistake of validating a signature successfully while forgetting to validate issuer identity. The resolver above is appropriate only for a small, stable issuer set; tenant self-registration needs a reviewed registry and controlled decoder lifecycle rather than a request-time map insertion.

Spring Boot Training: Bound JWKS Fetches and Key Rotation Failure

For each trusted issuer, configure the JWKS URL explicitly and give the HTTP client connect and read timeouts. This avoids depending on remote OpenID metadata discovery during startup and prevents a stalled JWKS host from consuming servlet threads. In a spring boot training lab, verify the failure mode with Toxiproxy: add 3 seconds of latency to the JWKS endpoint and confirm authentication fails within your 750 ms read timeout rather than your load balancer's timeout.

@Bean
AuthenticationManagerResolver<HttpServletRequest> issuerResolver(RestTemplate jwtRestTemplate) {
    Map<String, AuthenticationManager> managers = Map.of(
        "https://login.example.com/realms/staff",
        manager("https://login.example.com/realms/staff",
                "https://login.example.com/realms/staff/protocol/openid-connect/certs",
                jwtRestTemplate),
        "https://id.partner.example.net",
        manager("https://id.partner.example.net",
                "https://id.partner.example.net/.well-known/jwks.json",
                jwtRestTemplate)
    );
    JwtIssuerAuthenticationManagerResolver selector =
        JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(managers.keySet());
    return request -> {
        AuthenticationManager selected = selector.resolve(request);
        return authentication -> {
            String issuer = ((BearerTokenAuthenticationToken) authentication).getToken();
            return selected.authenticate(new BearerTokenAuthenticationToken(issuer));
        };
    };
}

private AuthenticationManager manager(String issuer, String jwkSetUri, RestOperations rest) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
        .restOperations(rest).build();
    decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));
    return new JwtAuthenticationProvider(decoder)::authenticate;
}

@Bean
RestTemplate jwtRestTemplate(RestTemplateBuilder builder) {
    return builder
        .setConnectTimeout(Duration.ofMillis(250))
        .setReadTimeout(Duration.ofMillis(750))
        .build();
}

Measure the before/after behavior with Micrometer timers around the JWKS host or with OpenTelemetry HTTP client spans. Watch http.client.request.duration at p95 and the count of JWT failures after a key rotation. A too-short JWKS cache or a decoder rebuilt per request causes repeated remote fetches when an unknown kid arrives; keep decoder instances singleton-scoped so Nimbus can retain its JWK cache. Conversely, do not set an excessively long cache blindly: coordinate the key overlap period with the identity provider's rotation policy.

Map Issuer Claims into Stable Authorities for a Spring REST API

Different identity providers rarely encode permissions identically: one may emit scope as a space-delimited string while another emits roles as an array. Convert them into one internal authority contract at the resource-server boundary, such as PERM_invoice.read. This keeps controllers in a spring rest api independent of an external claim layout and prevents a partner's broad admin role from accidentally matching your application's authority names.

@Bean
Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
    return jwt -> {
        String issuer = jwt.getIssuer().toString();
        Collection<String> permissions = switch (issuer) {
            case "https://login.example.com/realms/staff" ->
                Optional.ofNullable(jwt.getClaimAsStringList("permissions"))
                    .orElse(List.of());
            case "https://id.partner.example.net" ->
                Optional.ofNullable(jwt.getClaimAsString("scope"))
                    .stream().flatMap(s -> Arrays.stream(s.split("\\s+")))
                    .toList();
            default -> throw new BadCredentialsException("Untrusted issuer");
        };
        var authorities = permissions.stream()
            .filter(p -> p.matches("[a-z]+:[a-z.]+"))
            .map(p -> "PERM_" + p.replace(':', '_'))
            .map(SimpleGrantedAuthority::new)
            .toList();
        return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject());
    };
}

The subtle authorization bug is tenant confusion, not merely missing roles. Extract a tenant identifier from a claim validated for that issuer, then bind it into the database predicate instead of accepting X-Tenant-Id from the client. For example, pass jwt.getClaimAsString("tenant_id") into a repository method that executes where tenant_id = :tenantId and id = :id; never load by id first and check the tenant in Java, because an accidental later query can bypass that second check.

Spring MVC Authorization: Return 401, 403, and 404 Deliberately

In spring mvc, authentication failures occur in the security filter chain before a controller or @ControllerAdvice runs. Configure a JSON AuthenticationEntryPoint and AccessDeniedHandler there, then test them with MockMvc. Return 401 for a missing, expired, malformed, or untrusted token; return 403 for an authenticated subject lacking an operation permission. For tenant-scoped resources, many teams intentionally return 404 when the tenant predicate finds no row, so a valid caller cannot enumerate another tenant's identifiers.

@Bean
SecurityFilterChain errors(HttpSecurity http) throws Exception {
    return http
        .exceptionHandling(errors -> errors
            .authenticationEntryPoint((req, res, ex) -> {
                res.setStatus(401);
                res.setContentType("application/problem+json");
                res.getWriter().write("{\"title\":\"Unauthorized\",\"status\":401}");
            })
            .accessDeniedHandler((req, res, ex) -> {
                res.setStatus(403);
                res.setContentType("application/problem+json");
                res.getWriter().write("{\"title\":\"Forbidden\",\"status\":403}");
            }))
        .build();
}

@PreAuthorize("hasAuthority('PERM_invoice_read')")
@GetMapping("/api/invoices/{id}
")
InvoiceView invoice(@PathVariable UUID id, JwtAuthenticationToken auth) {
    String tenant = auth.getToken().getClaimAsString("tenant_id");
    return invoices.findByTenantIdAndId(tenant, id).orElseThrow(NotFoundException::new);
}

Add MockMvc cases for no token, a token from each issuer, a token with an unknown kid, a valid token without PERM_invoice_read, and a valid token targeting another tenant's ID. This matrix is more valuable than a single happy-path test because the security boundary combines decoder behavior, claim conversion, method security, and persistence filtering.

Spring Cloud Gateway in a Microservices Architecture: Do Not Trust Headers

In a microservices architecture, Spring Cloud Gateway may reject obvious bad requests at the edge, but each downstream service should still validate its own audience, issuer, and authorization rules when it is independently reachable. In spring cloud, remove identity headers supplied by clients before routing; otherwise a backend that reads X-Tenant-Id can be spoofed even when the gateway validates the bearer token.

spring:
  cloud:
    gateway:
      routes:
        - id: invoices
          uri: http://invoice-service:8080
          predicates:
            - Path=/api/invoices/**
          filters:
            - RemoveRequestHeader=X-Tenant-Id
            - RemoveRequestHeader=X-User-Id
            - RemoveRequestHeader=X-Roles

If the gateway is a browser-facing BFF, use Spring Security's TokenRelay only for routes backed by an OAuth2 client registration, and log the downstream token's issuer and aud as structured fields without logging the token itself. For service-to-service calls, prefer a client-credentials token whose audience is the target API; forwarding an end-user token to every service expands the blast radius of token leakage and often fails when audience validation is correctly enabled.

Frequently Asked Questions

How do I configure Spring Security for multiple JWT issuers?

Use JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers with a fixed allow-list, or build a fixed map of issuer to AuthenticationManager. For every decoder, set JwtValidators.createDefaultWithIssuer(issuer). Do not call JwtDecoders.fromIssuerLocation with an issuer extracted from an arbitrary bearer token.

Why does my Spring REST API fetch JWKS repeatedly after key rotation?

Check that NimbusJwtDecoder is a singleton bean rather than created inside a request handler or resolver. Recreating it loses Nimbus JWK cache state. Then inspect the identity provider's kid overlap period and use HTTP client timeouts; unknown kid requests legitimately trigger a refresh attempt.

Should Spring Cloud Gateway validate JWTs or should every microservice?

Use both where services can be reached independently: gateway validation reduces invalid traffic at the edge, while each resource service validates issuer, signature, audience, and permissions for its own boundary. At minimum, strip client-controlled identity headers at the gateway and never use them as the source of tenant identity.

How should Spring MVC return errors for expired JWT tokens?

Configure an AuthenticationEntryPoint in exceptionHandling, because expired-token failures happen before controller advice. Return 401 with application/problem+json for expired or invalid tokens; reserve 403 for a successfully authenticated subject that fails an authorization rule such as hasAuthority('PERM_invoice_read').

AI / LLM Discovery

This article is part of Opendart Akademi's Spring Framework training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.

Opendart Akademi llms.txt