Spring Boot Security tokens Validation locally using Keycloak’s public keys (JWKS)

Published: (December 16, 2025 at 03:46 AM EST)
6 min read
Source: Dev.to

Source: Dev.to

Actors

ActorRole
Keycloak (Authorization Server / IdP)• Authenticates users/clients via OIDC/OAuth2 flows (authorization code, client credentials, etc.)
• Issues JWT access tokens containing claims such as iss, sub, exp, iat, nbf, aud, scope, preferred_username, realm_access, …
• Publishes its signing keys at a JWKS endpoint under the realm
Spring Boot API (Resource Server)• Exposes protected REST endpoints
• Uses Spring Security’s OAuth2 Resource Server support to:
 - Extract Authorization: Bearer <token> from incoming requests
 - Validate JWT signature and claims locally using Keycloak’s JWKS
 - Map claims/scopes to authorities and enforce access rules with @PreAuthorize, hasRole, etc.
Client (SPA, mobile app, B2B, …)• Obtains tokens from Keycloak via the appropriate OAuth2 flow
• Sends the access token as a Bearer token when calling the Spring APIs

Critical point: once the token is issued, the Spring service only needs Keycloak’s public keys, not a per‑request call to Keycloak, to verify the token.

Token Issuance Flow (Simplified)

  1. User or backend client logs in (browser redirect / OIDC or client‑credentials).

  2. Keycloak verifies credentials, builds the user’s identity & roles, then issues an access token (and optionally a refresh token).

  3. The access token is a JWT, signed with Keycloak’s private key (identified by a kid).

  4. Keycloak exposes the corresponding public keys at:

    https://<host>/realms/<realm>/.well-known/openid-configuration   (metadata)
    https://<host>/realms/<realm>/protocol/openid-connect/certs   (JWKS)
  5. The client now holds a self‑contained, signed JWT. No further call‑back to Keycloak is needed for verification, as long as the resource server knows the public key.

Configuring Spring Security as an OAuth2 Resource Server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • Spring calls /.well-known/openid-configuration for the issuer.
  • Reads jwks_uri from the metadata.
  • Builds a JwtDecoder using the Keycloak JWKS endpoint.
  • Enforces issuer validation (iss claim must match the configured issuer).

Benefit: Simple & robust – if Keycloak changes the JWKS URL but keeps the same issuer, the metadata stays consistent.

Option B – JWKS‑Set‑URI (direct)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
  • Skips discovery and points directly at the JWKS endpoint.
  • Usually combined with issuer-uri or a custom validator to still enforce iss.

Caching of JWKS

Spring’s default JWT support (Nimbus) caches the keys:

  • Fetches the JWKS from Keycloak once (or when needed).
  • Stores keys in memory.
  • Refreshes according to its cache policy (e.g., when a new kid appears after key rotation).

Result: No per‑request call to Keycloak; only occasional fetches when the cache expires or a new key is introduced.

Request Processing Flow (per API call)

  1. Incoming HTTP request

    Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
  2. Bearer token extraction – Spring Security’s filter chain extracts the token and passes it to the configured JwtDecoder.

  3. Token parsing & signature verification

    • Decode JWT header → read kid.
    • Look up the matching public key from the JWKS cache.
    • Verify the signature (e.g., RS256).
    • If verification fails, authentication is rejected.
  4. Claim validation (standard checks)

    • exp – not expired.
    • nbf – valid now.
    • iat – optional custom validation.
    • iss – must match the configured issuer.
    • aud – optional, should match your API’s expected audience/client‑id.
  5. Authentication object creation

    • A Jwt object is created and wrapped in a JwtAuthenticationToken with authorities derived from token claims.
    • Stored in SecurityContextHolder.
  6. Authorization at controller/service level

    @PreAuthorize("hasAuthority('SCOPE_my-scope')")
    @PreAuthorize("hasRole('admin')")   // after mapping

    Decisions are based purely on the validated token – no Keycloak call.

  7. Failure handling – Missing/invalid token, bad signature, wrong issuer, or expired token results in 401 Unauthorized or 403 Forbidden.

Maven Dependencies

<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>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
    <optional>true</optional>
</dependency>

Example application.yml (partial)

server:
  port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Choose one of the two options:
          issuer-uri: https://keycloak.example.com/realms/my-realm
          # jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs

TL;DR

  • Keycloak → issues signed JWTs & publishes JWKS.
  • Spring Boot (Resource Server) → validates JWT locally using the cached JWKS (via issuer-uri or jwk-set-uri).
  • Clients → send the JWT as a Bearer token.

No per‑request round‑trip to Keycloak is required, while you still benefit from key rotation, claim‑based security, and Spring’s powerful authorization annotations.

port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm

Security Configuration (Java)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            );

        return http.build();
    }
}

Mapping Keycloak Roles to Spring Authorities

Keycloak places roles in claims such as realm_access.roles and resource_access.<client>.roles. The following converter maps those to Spring ROLE_ authorities:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

@Configuration
public class JwtConverterConfig {

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> defaultAuthorities = defaultConverter.convert(jwt);

            Collection<GrantedAuthority> realmRoles = jwt.getClaimAsMap("realm_access") != null
                    ? ((Collection<String>) ((Map<?, ?>) jwt.getClaim("realm_access"))
                        .getOrDefault("roles", List.of()))
                        .stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .toList()
                    : List.of();

            return Stream.concat(defaultAuthorities.stream(), realmRoles.stream())
                    .toList();
        });

        return converter;
    }
}

Wire the converter into the security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)

Now annotations such as @PreAuthorize("hasRole('admin')") work with Keycloak’s realm role admin.

Additional JWT Validation (Audience, Tenant, etc.)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.*;

import java.util.List;

@Configuration
public class JwtDecoderConfig {

    private static final String EXPECTED_AUDIENCE = "my-api";

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder =
                JwtDecoders.fromIssuerLocation("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> withIssuer =
                JwtValidators.createDefaultWithIssuer("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
            List<String> audiences = jwt.getAudience();
            if (audiences != null && audiences.contains(EXPECTED_AUDIENCE)) {
                return OAuth2TokenValidatorResult.success();
            }
            OAuth2Error error = new OAuth2Error("invalid_token",
                    "The required audience is missing", null);
            return OAuth2TokenValidatorResult.failure(error);
        };

        jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
        return jwtDecoder;
    }
}

Plug the decoder into the security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.decoder(jwtDecoder()))
)

JWKS Caching & Rotation Detection

To fetch the JWKS more frequently (e.g., every minute) while keeping validation local:

  1. Use a NimbusJwtDecoder (fromIssuerLocation or withJwkSetUri).
  2. Customize the JWKS source with a caching layer (Spring Cache, Caffeine, Redis, etc.).
  3. Set a TTL (e.g., 60 seconds) for the JWKS entry.

This approach lets each instance refresh the JWKS periodically, detecting key rotation quickly without impacting request‑level performance.

Tip: Prefer JWT validation (resource‑server mode) over token introspection for better performance and reduced coupling.

Summary of Key Properties

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • Configure SecurityFilterChain with role‑based access.
  • Provide a custom JwtAuthenticationConverter to map Keycloak realm roles to ROLE_ authorities.
  • Add an optional JwtDecoder bean for audience (or other claim) validation.
  • Adjust JWKS cache TTL to detect key rotation as often as needed (e.g., every 60 seconds).

Keycloak Integration Tips

  • jwk-set-uri – configure this so Spring can fetch Keycloak’s JWKS.
  • Rely on JWKS caching to avoid calling Keycloak on every request; tune the cache TTL based on rotation sensitivity versus load.
  • Enforce claims such as iss, exp, nbf, aud (and any domain‑specific constraints) via JwtDecoder validators.
  • Map Keycloak roles/claims to Spring authorities with a custom JwtAuthenticationConverter to keep authorization logic clean.
Back to Blog

Related posts

Read more »