Spring Boot Security tokens Validation locally using Keycloak’s public keys (JWKS)
Source: Dev.to
Actors
| Actor | Role |
|---|---|
| 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)
-
User or backend client logs in (browser redirect / OIDC or client‑credentials).
-
Keycloak verifies credentials, builds the user’s identity & roles, then issues an access token (and optionally a refresh token).
-
The access token is a JWT, signed with Keycloak’s private key (identified by a
kid). -
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) -
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
Option A – Issuer‑URI (recommended)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
- Spring calls
/.well-known/openid-configurationfor the issuer. - Reads
jwks_urifrom the metadata. - Builds a
JwtDecoderusing the Keycloak JWKS endpoint. - Enforces issuer validation (
issclaim 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-urior a custom validator to still enforceiss.
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
kidappears 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)
-
Incoming HTTP request
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0... -
Bearer token extraction – Spring Security’s filter chain extracts the token and passes it to the configured
JwtDecoder. -
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.
- Decode JWT header → read
-
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.
-
Authentication object creation
- A
Jwtobject is created and wrapped in aJwtAuthenticationTokenwith authorities derived from token claims. - Stored in
SecurityContextHolder.
- A
-
Authorization at controller/service level
@PreAuthorize("hasAuthority('SCOPE_my-scope')") @PreAuthorize("hasRole('admin')") // after mappingDecisions are based purely on the validated token – no Keycloak call.
-
Failure handling – Missing/invalid token, bad signature, wrong issuer, or expired token results in
401 Unauthorizedor403 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-uriorjwk-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:
- Use a
NimbusJwtDecoder(fromIssuerLocationorwithJwkSetUri). - Customize the JWKS source with a caching layer (Spring Cache, Caffeine, Redis, etc.).
- 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
SecurityFilterChainwith role‑based access. - Provide a custom
JwtAuthenticationConverterto map Keycloak realm roles toROLE_authorities. - Add an optional
JwtDecoderbean 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) viaJwtDecodervalidators. - Map Keycloak roles/claims to Spring authorities with a custom
JwtAuthenticationConverterto keep authorization logic clean.