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
- User logs into Keycloak (via browser redirect / OIDC) or a backend client uses client credentials.
- Keycloak verifies credentials, builds the user’s identity and roles, then issues an access token (and optionally a refresh token).
- The access token is a JWT, signed with Keycloak’s private key associated with a
kid(key id). - 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
With issuer-uri, Spring:
- Calls
/.well-known/openid-configurationfor that issuer. - Reads the
jwks_urifrom the metadata. - Builds a
JwtDecoderusing the Keycloak JWKS endpoint. - Configures issuer validation so that
issin 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
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:
-
Incoming HTTP request
- Client sends:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
- Client sends:
-
Bearer token extraction
- Spring Security’s filter chain detects
Authorizationheader withBearer. - It extracts the token and passes it to the configured
JwtDecoder.
- Spring Security’s filter chain detects
-
Token parsing and signature verification
- The JWT header is decoded; the
kidis 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.
- The JWT header is decoded; the
-
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.
-
Authentication object creation
- If signature and claims are valid, Spring builds a
Jwtobject (the parsed token). - It wraps it in a
JwtAuthenticationTokenwith authorities derived from token claims (scopes or roles). - It stores this authentication in
SecurityContextHolder.
- If signature and claims are valid, Spring builds a
-
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.
- When your controller is hit, you can use annotations such as:
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>
Application configuration (issuer-based)
server:
port: 8080
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
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();
}
}
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;
}
}
Wire it into the security config:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
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;
}
}
Plug this jwtDecoder into your security config:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
)
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:
- Use a
NimbusJwtDecoderwithfromIssuerLocationorwithJwkSetUri. - Customize the JWKS source with a caching layer (e.g., using Nimbus config or Spring’s caching abstraction).
- 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-uriorjwk-set-uriso 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,audand any domain-specific constraints viaJwtDecodervalidators. -
Map Keycloak roles/claims to Spring authorities with a custom
JwtAuthenticationConverterto keep authorization logic clean.
Top comments (0)