DEV Community

DEV-AI
DEV-AI

Posted on

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

In a Spring Security + Keycloak architecture, Keycloak issues signed JWT access tokens and your Spring Boot services validate those tokens locally using Keycloak’s public keys (JWKS), not by calling Keycloak for each request. This gives you high performance, low coupling, and still supports features like key rotation and claim-based authorization.

Big picture architecture

In this model, there are three main actors:

  • 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, and so on.
    • Publishes its signing keys in a JWKS (JSON Web Key Set) 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, backend-to-backend, etc.)

    • Obtains tokens from Keycloak using the appropriate OAuth2 flow.
    • Sends the access token as a Bearer token when calling the Spring APIs.

The critical point: once the token is issued, your Spring service only needs Keycloak’s public keys, not a per-request call to Keycloak, to check if the token is valid.


Token lifecycle and request flow

1. Authentication and token issuance

  1. User logs into Keycloak (via browser redirect / OIDC) or a backend client uses client credentials.
  2. Keycloak verifies credentials, builds the user’s identity and roles, then issues an access token (and optionally a refresh token).
  3. The access token is a JWT, signed with Keycloak’s private key associated with a kid (key id).
  4. Keycloak exposes the corresponding public keys at:
    • https://<keycloak-host>/realms/<realm-name>/.well-known/openid-configuration (metadata)
    • https://<keycloak-host>/realms/<realm-name>/protocol/openid-connect/certs (JWKS)

The client now holds a self-contained, signed JWT. No call back to Keycloak is needed for a resource server to verify it, as long as the server knows the public key.

2. Service startup (Key discovery and cache)

At application startup, Spring Security is configured as an OAuth2 Resource Server. There are two main ways to plug it into Keycloak.

Option A: Using issuer-uri (recommended)

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

With issuer-uri, Spring:

  1. Calls /.well-known/openid-configuration for that issuer.
  2. Reads the jwks_uri from the metadata.
  3. Builds a JwtDecoder using the Keycloak JWKS endpoint.
  4. Configures issuer validation so that iss in the JWT must match this issuer.

This makes your config simple and robust: if Keycloak changes the JWKS URL but keeps the issuer, the metadata stays consistent.

Option B: Using jwk-set-uri directly

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
Enter fullscreen mode Exit fullscreen mode

This skips discovery and goes directly to the JWKS endpoint. Often this is combined with issuer-uri or a custom validator to still enforce iss.

What about caching?

Spring’s default JWT support uses Nimbus under the hood, which:

  • Fetches the JWKS from Keycloak.
  • Caches the keys in memory.
  • Uses those keys for signature verification until they expire/refresh according to its cache policy.

This means no per-request call to Keycloak. Only occasional fetches when the JWKS is not cached, has expired, or a new kid appears (e.g. after key rotation).


3. Per-request validation flow inside Spring Security

For each API call:

  1. Incoming HTTP request

    • Client sends: Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
  2. Bearer token extraction

    • Spring Security’s filter chain detects Authorization header with Bearer.
    • It extracts the token and passes it to the configured JwtDecoder.
  3. Token parsing and signature verification

    • The JWT header is decoded; the kid is read.
    • The decoder looks up the right public key from the JWKS cache (by kid).
    • It verifies the signature using the key (e.g. RS256).
    • If the signature is invalid or no matching key is found (even after refresh), authentication fails.
  4. Claim validation

    Standard validations typically include:

    • exp (expiration) – token must not be expired.
    • nbf (not before) – token must be valid at the current time.
    • iat (issued at) – can be used in custom validations if needed.
    • iss (issuer) – must match the configured issuer.
    • Optional aud (audience) – should match your API’s expected audience / client ID.

Additional checks are easy to plug in for tenant, realm, etc.

  1. Authentication object creation

    • If signature and claims are valid, Spring builds a Jwt object (the parsed token).
    • It wraps it in a JwtAuthenticationToken with authorities derived from token claims (scopes or roles).
    • It stores this authentication in SecurityContextHolder.
  2. Authorization at controller/service level

    • When your controller is hit, you can use annotations such as:
      • @PreAuthorize("hasAuthority('SCOPE_my-scope')")
      • @PreAuthorize("hasRole('admin')") (after mapping).
    • The decision is made purely on the validated token; no Keycloak call is needed.

If anything fails (missing/invalid token, invalid signature, wrong issuer, expired token), the request is rejected with 401 or 403 depending on the scenario.


4. Code snippets: minimal working setup

Maven/Gradle dependencies

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 if you only do resource-server -->
        <optional>true</optional>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Application configuration (issuer-based)

server:
  port: 8080

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

Java security configuration (Spring Boot 3 / Spring Security 6)

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Customizing JWT -> authorities mapping (Keycloak roles)

Keycloak typically puts roles in claims such as realm_access.roles and resource_access.<clientId>.roles. You can adapt that to Spring 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.stream.Stream;

@Configuration
public class JwtConverterConfig {

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();
        // Optionally, defaultConverter.setAuthorityPrefix("ROLE_");
        // defaultConverter.setAuthoritiesClaimName("scope");

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

            // Example: map Keycloak realm roles
            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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Wire it into the security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
Enter fullscreen mode Exit fullscreen mode

Now @PreAuthorize("hasRole('admin')") or hasAuthority('ROLE_admin') will work with Keycloak’s realm role admin.


5. Customizing JWT validation and audience

You can add extra validation logic on top of signature and time checks, such as audience (aud) validation or tenant checks.

Audience validation example

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.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Plug this jwtDecoder into your security config:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.decoder(jwtDecoder()))
)
Enter fullscreen mode Exit fullscreen mode

6. Controlling how often JWKS is refreshed

You mentioned a desire like “make it every 1 [minute] if needed”. The idea is:

  • Token validation is always local.
  • JWKS fetching should be infrequent but responsive to key rotation.
  • You can control the JWKS cache TTL, especially if you want shorter rotation detection (e.g., 60 seconds).

A typical approach:

  1. Use a NimbusJwtDecoder with fromIssuerLocation or withJwkSetUri.
  2. Customize the JWKS source with a caching layer (e.g., using Nimbus config or Spring’s caching abstraction).
  3. Configure your cache provider (Caffeine, Redis, Hazelcast) with a TTL (e.g., 60 seconds) for the JWKS key entry.

That way, your service only calls Keycloak for JWKS roughly once per minute per instance (or per cluster if using a distributed cache), and all request-level validation remains local.


7. Summary of key design decisions

  • Use JWT validation (resource server) instead of token introspection for performance and reduced coupling.
  • Configure issuer-uri or jwk-set-uri so Spring can fetch Keycloak’s JWKS.
  • Rely on JWKS caching to avoid calling Keycloak per request; tune cache TTL for rotation sensitivity vs. 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.

Top comments (0)