Spring Security 6 OAuth2 Resource Server: Keycloak & Auth0 Integration

Building a production Spring Boot API in 2026 means integrating with an external identity provider — Keycloak, Auth0, Okta, or Azure AD. Spring Security 6 has first-class OAuth2 Resource Server support: you provide a JWKS URI and the framework handles signature verification, expiry, issuer validation, and principal extraction automatically. This guide covers the full configuration: from minimal YAML to audience validators, custom claim converters, and multi-tenant JWT routing.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Spring Security

Spring Security April 4, 2026 17 min read Spring Security Series
Spring Security OAuth2 resource server Keycloak Auth0 integration

The OAuth2 Resource Server Architecture

Spring Security OAuth2 Resource Server Architecture | mdsanwarhossain.me
Spring Security OAuth2 Resource Server Architecture — mdsanwarhossain.me

In the OAuth2 model, your Spring Boot API is the Resource Server — it holds the protected resources (user data, orders, etc.). A separate Authorization Server (Keycloak, Auth0, Okta) issues JWTs. Clients authenticate with the Authorization Server, receive a JWT access token, and then present that token as a Bearer header on every call to your Resource Server.

Your Resource Server's job is to:

  1. Extract the JWT from the Authorization: Bearer <token> header.
  2. Fetch the public key from the Authorization Server's JWKS (JSON Web Key Set) endpoint.
  3. Verify the JWT signature, expiry (exp), issuer (iss), and optionally audience (aud).
  4. Map the JWT claims to a Spring Security Authentication object.
  5. Allow your controllers to use @AuthenticationPrincipal, @PreAuthorize, etc. as normal.

Spring Security handles steps 2–4 automatically once configured. The configuration is minimal — typically just a YAML property or two.

Maven Dependencies

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- spring-security-oauth2-jose is included transitively -->

Keycloak Integration — Minimal Configuration

With a Keycloak realm, the minimal configuration is a single YAML property. Spring Security auto-discovers the JWKS URI, token endpoint, and issuer from the OIDC discovery document at /.well-known/openid-configuration.

# application.yml — Keycloak
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://keycloak:8080/realms/my-realm
          # Spring auto-fetches:
          # http://keycloak:8080/realms/my-realm/.well-known/openid-configuration
          # and extracts the jwks_uri from the discovery document
// SecurityConfig.java — minimal
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())  // uses issuer-uri from YAML
            );
        return http.build();
    }
}

That is the entire configuration for a basic Keycloak integration. Spring Security fetches the Keycloak public key via JWKS on startup, caches it, and rotates it automatically when Keycloak rotates keys.

Auth0 Integration — Audience Validation Required

Spring Security JWT Filter Chain | mdsanwarhossain.me
Spring Security JWT Filter Chain — mdsanwarhossain.me

Auth0 tokens include an aud (audience) claim that identifies the API the token is intended for. Spring Security's default JWT decoder does not validate aud unless you configure it — this is a required step for Auth0.

# application.yml — Auth0
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-tenant.auth0.com/
          # Auth0 JWKS: https://your-tenant.auth0.com/.well-known/jwks.json

# Add custom properties for audience validation
auth0:
  audience: https://api.your-app.com
// AudienceValidator.java — required for Auth0
@Component
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {

    private final String audience;

    public AudienceValidator(@Value("${auth0.audience}") String audience) {
        this.audience = audience;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        List<String> audiences = jwt.getAudience();
        if (audiences != null && audiences.contains(audience)) {
            return OAuth2TokenValidatorResult.success();
        }
        OAuth2Error error = new OAuth2Error(
            "invalid_token",
            "JWT does not contain required audience: " + audience,
            null
        );
        return OAuth2TokenValidatorResult.failure(error);
    }
}
// SecurityConfig.java — Auth0 with audience validation
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuerUri;

    private final AudienceValidator audienceValidator;

    public SecurityConfig(AudienceValidator audienceValidator) {
        this.audienceValidator = audienceValidator;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.decoder(jwtDecoder()))
            );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuerUri);

        // Compose validators: default (issuer + expiry) + audience
        OAuth2TokenValidator<Jwt> defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri);
        OAuth2TokenValidator<Jwt> withAudience =
            new DelegatingOAuth2TokenValidator<>(defaultValidators, audienceValidator);

        decoder.setJwtAuthenticationConverter(jwtAuthenticationConverter());
        decoder.setJwtValidator(withAudience);
        return decoder;
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
        authoritiesConverter.setAuthoritiesClaimName("permissions"); // Auth0 uses 'permissions'
        authoritiesConverter.setAuthorityPrefix("SCOPE_");

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
        return converter;
    }
}

Custom Claims Converter: Mapping Keycloak Roles

Keycloak stores roles in a nested structure: realm_access.roles or resource_access.<client-id>.roles. Spring Security's default converter does not understand this structure — you need a custom JwtAuthenticationConverter.

// KeycloakRoleConverter.java
@Component
public class KeycloakRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    private static final String REALM_ROLES_CLAIM = "realm_access";
    private static final String ROLES_KEY = "roles";

    @Override
    @SuppressWarnings("unchecked")
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaim(REALM_ROLES_CLAIM);
        if (realmAccess == null) return Collections.emptyList();

        List<String> roles = (List<String>) realmAccess.get(ROLES_KEY);
        if (roles == null) return Collections.emptyList();

        return roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
            .collect(Collectors.toList());
    }
}

// SecurityConfig.java — integrate Keycloak converter
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter(KeycloakRoleConverter roleConverter) {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(roleConverter);
    converter.setPrincipalClaimName("preferred_username"); // Keycloak's user identifier
    return converter;
}

After this configuration, @PreAuthorize("hasRole('ADMIN')") works correctly when the Keycloak user has the admin realm role.

Accessing JWT Claims in Controllers

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    // Access the full JWT principal
    @GetMapping("/my-orders")
    public List<Order> getMyOrders(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();          // 'sub' claim
        String username = jwt.getClaim("preferred_username"); // Keycloak specific
        String tenantId = jwt.getClaim("tenant_id");          // custom claim
        return orderService.findByUser(userId, tenantId);
    }

    // Access the typed Authentication object
    @GetMapping("/admin/all")
    @PreAuthorize("hasRole('ADMIN')")
    public List<Order> getAllOrders(Authentication authentication) {
        JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) authentication;
        Jwt jwt = (Jwt) jwtAuth.getCredentials();
        log.info("Admin access by: {}", jwt.getSubject());
        return orderService.findAll();
    }

    // Combine JWT claims with path variables for ownership check
    @GetMapping("/{orderId}")
    @PreAuthorize("@orderSecurity.isOwner(#orderId, authentication)")
    public Order getOrder(@PathVariable String orderId) {
        return orderService.findById(orderId);
    }
}

// OrderSecurity.java — custom security expression bean
@Component("orderSecurity")
public class OrderSecurity {
    private final OrderRepository orderRepo;

    public boolean isOwner(String orderId, Authentication auth) {
        JwtAuthenticationToken jwtAuth = (JwtAuthenticationToken) auth;
        String userId = ((Jwt) jwtAuth.getCredentials()).getSubject();
        return orderRepo.existsByIdAndUserId(orderId, userId);
    }
}

Multi-Tenant JWT Routing

Some architectures need to accept JWTs from multiple identity providers — e.g., Keycloak for internal users and Auth0 for external partners. Spring Security supports this via a custom AuthenticationManagerResolver.

// MultiTenantJwtDecoder.java
@Component
public class MultiTenantJwtDecoder implements JwtDecoder {

    private final Map<String, JwtDecoder> decoderCache = new ConcurrentHashMap<>();

    @Override
    public Jwt decode(String token) throws JwtException {
        // Decode without verification to read the 'iss' claim
        String issuer = extractIssuerWithoutVerification(token);

        JwtDecoder decoder = decoderCache.computeIfAbsent(issuer, iss -> {
            if (iss.contains("keycloak")) {
                return JwtDecoders.fromIssuerLocation(iss);
            } else if (iss.contains("auth0.com")) {
                return buildAuth0Decoder(iss);
            }
            throw new JwtException("Unknown issuer: " + iss);
        });

        return decoder.decode(token);
    }

    private String extractIssuerWithoutVerification(String token) {
        try {
            JWT parsed = JWTParser.parse(token);
            return parsed.getJWTClaimsSet().getIssuer();
        } catch (ParseException e) {
            throw new JwtException("Cannot parse JWT", e);
        }
    }

    private JwtDecoder buildAuth0Decoder(String issuer) {
        NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
        // Add audience validator for Auth0
        OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer(issuer),
            jwt -> {
                List<String> aud = jwt.getAudience();
                return (aud != null && aud.contains("https://api.your-app.com"))
                    ? OAuth2TokenValidatorResult.success()
                    : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));
            }
        );
        decoder.setJwtValidator(validators);
        return decoder;
    }
}

// SecurityConfig.java — use the multi-tenant decoder
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(multiTenantJwtDecoder)))

JWKS Caching and Key Rotation

Spring Security's default NimbusJwtDecoder caches the JWKS for 5 minutes and automatically re-fetches when it encounters a signature verification failure with an unknown key ID (kid). This handles key rotation transparently for most scenarios. However, for high-security environments you may want to tune this:

@Bean
public JwtDecoder jwtDecoder() {
    // Build decoder with explicit JWKS URI (bypasses issuer discovery overhead)
    return NimbusJwtDecoder
        .withJwkSetUri("https://keycloak:8080/realms/my-realm/protocol/openid-connect/certs")
        .jwsAlgorithm(SignatureAlgorithm.RS256)  // accept only RS256 — reject HS256
        .cache(
            new NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder.CachingDefaults()
                .jwkSetCache(Duration.ofMinutes(10))  // cache JWKS for 10 minutes
        )
        .build();
}

Security Hardening Checklist

Check Keycloak Auth0
Signature algorithmRS256 (default)RS256 (default)
Issuer validationAuto via issuer-uriAuto via issuer-uri
Audience validationOptional (client IDs)Required — AudienceValidator
Role claimrealm_access.rolespermissions (custom)
JWKS auto-rotationYes (unknown kid)Yes (unknown kid)
Session policySTATELESSSTATELESS
CSRF disabledYes (stateless API)Yes (stateless API)

Testing the Resource Server

// Integration test with @WithMockUser or mocked JWT
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @Test
    void getMyOrders_withValidJwt_returns200() throws Exception {
        mvc.perform(get("/api/v1/orders/my-orders")
                .with(jwt()
                    .jwt(builder -> builder
                        .subject("user-123")
                        .claim("preferred_username", "alice")
                        .claim("tenant_id", "tenant-acme")
                    )
                )
            )
            .andExpect(status().isOk());
    }

    @Test
    void adminEndpoint_withoutAdminRole_returns403() throws Exception {
        mvc.perform(get("/api/v1/orders/admin/all")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER")))
            )
            .andExpect(status().isForbidden());
    }

    @Test
    void adminEndpoint_withAdminRole_returns200() throws Exception {
        mvc.perform(get("/api/v1/orders/admin/all")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
            )
            .andExpect(status().isOk());
    }
}

The SecurityMockMvcRequestPostProcessors.jwt() DSL (from spring-security-test) creates a valid mock JWT without requiring a real identity provider in your test environment. For a deeper look at method-level authorization with @PreAuthorize, see the companion post on Spring Security Method-Level Security and RBAC patterns.

Conclusion

Spring Security 6's OAuth2 Resource Server support is production-ready and requires minimal boilerplate. For Keycloak, a single YAML property enables full JWT validation with automatic key rotation. For Auth0, add an AudienceValidator and a custom claims converter. For multi-tenant scenarios, implement a custom JwtDecoder that routes by issuer. The framework handles the cryptographic heavy lifting — your job is configuration, claim mapping, and method-level authorization.

For the custom JWT filter approach (when you manage your own tokens rather than delegating to an IdP), see the Spring Boot Security with JWT guide which covers token lifecycle, refresh rotation, and blacklisting in detail.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Spring Security

Last updated: April 4, 2026