Turning a Spring Boot API into an OAuth 2.0 resource server that accepts Keycloak tokens takes one dependency and one property. Within an hour every endpoint answers 200 to a valid token and 401 to anything else, and it is tempting to call the API secured.
It is not. At that point you have answered one question, “is this a valid token?”, and authorization is at least three more questions. The one that turns into a data breach, “may this user touch this record?”, is the one no framework answers for you. This post walks through the four layers we put in every resource server, how we key identity so that a changed e-mail address never moves anyone’s data, how one artifact can trust several Keycloak realms safely, and two defaults we decide on deliberately instead of inheriting.
Tested with: Java 21, Spring Boot 4.1.0 (Spring Security 7.1.0, via spring-boot-starter-oauth2-resource-server), Keycloak as the identity provider.
Four questions, four layers
Keeping these apart is most of the design. Mixing them is how a 403 ends up in the wrong place, or a record ends up readable by the wrong person.
- Is this a valid token at all? The resource-server filter checks signature, issuer and expiry. Get it wrong and anyone can call anything.
- Is this caller one of our users? A coarse gate in the filter chain. Get it wrong and any token from the realm reaches your API, including one minted for an unrelated application.
- May this role call this operation?
@PreAuthorize. Get it wrong and a reader can approve. - May this user touch this record? An ownership check in the service layer. Get it wrong and user A reads user B’s data.
Layer 4 is the only one whose failure is invisible in testing: every endpoint returns 200, just for the wrong caller. So our working rule is that an endpoint is not finished when it returns 200 for us. It is finished when we have decided, explicitly, what layers 3 and 4 say about it.
Layer 1 and 2: the filter chain
@Configuration
@EnableWebSecurity
public class SecurityConfig {
public static final String CLERK = "OrderClerk";
public static final String MANAGER = "OrderManager";
static final String[] PUBLIC_PATHS = {
"/actuator/health", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().hasAnyRole(CLERK, MANAGER))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
Every line has a reason. STATELESS because the token is the security context: no session, no sticky routing, any instance serves any request. CSRF is off because CSRF protects credentials the browser attaches by itself, which means cookies; a bearer token in an Authorization header is attached by your code, so there is nothing to forge. If the same API also accepts a session cookie anywhere, that argument no longer holds and CSRF stays on.
The public paths are the health probe and the API documentation, and the documentation is open on development environments only. On every non-development deployment the environment sets springdoc.api-docs.enabled=false and springdoc.swagger-ui.enabled=false, so those paths answer 404 and the map of the API is never served to an anonymous caller. The public paths are also closed at the network level: outside development, the firewall and reverse proxy in front of the service block the documentation paths entirely and let only the load balancer and the monitoring system reach the health probe. The filter chain stays the same in every environment; only what is deployed behind it changes.
The coarse gate names roles. A bare .anyRequest().authenticated() admits every valid token the realm has ever minted, for every client in it. That is authentication with extra steps, not authorization.
We keep this class in the application, even when the decoders and claim readers behind it live in a shared library. Which paths are public and which roles reach /api/** is the security boundary of that deployable, and whoever audits it should read one class, not trace a parameterised base class across three services. Share the plumbing; keep the boundary local.
The issuer goes in configuration, from the environment, so one artifact runs everywhere:
spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_ISSUER_URI}
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${OIDC_JWK_SET_URI}
Layer 2, continued: Keycloak roles are not where Spring looks
By default Spring Security turns the scope / scp claim into SCOPE_* authorities. Keycloak puts realm roles somewhere else entirely:
{
"iss": "https://sso.example.com/realms/shop",
"sub": "f4c3b0de-…",
"realm_access": { "roles": ["OrderClerk"] },
"resource_access": { "orders-web": { "roles": ["export"] } }
}
So we convert realm_access.roles into authorities with the ROLE_ prefix, because hasRole("OrderClerk") looks for the authority ROLE_OrderClerk:
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
if (!(jwt.getClaims().get("realm_access") instanceof Map<?, ?> realmAccess)) {
return List.of(); // no roles is legitimate, not an error
}
if (!(realmAccess.get("roles") instanceof List<?> roles)) {
return List.of();
}
return roles.stream()
.filter(Objects::nonNull)
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
.toList();
});
return converter;
}
Two details matter. A token with no roles is an account in no group, a normal state, so the converter returns an empty list and lets the filter chain refuse it with a 403. Throwing here turns that 403 into a 500. And the parsing is defensive with pattern matching rather than casts, because a differently configured realm can produce a different claim shape.
When the identity provider’s roles are coarse but endpoints need finer permissions, we derive application roles from the realm roles inside this converter and add them as extra authorities. Permission changes then become a code review instead of an identity-provider ticket. The cost is that Keycloak no longer tells the whole story, so the mapping has to be documented where people will look for it.
Layer 3: per-operation roles
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig { }
@PreAuthorize("hasRole('OrderManager')")
public void approve(Long orderId) { … }
Annotate the operations whose requirement differs from the coarse gate. If every endpoint needs the same roles, the filter chain already said so; repeating it on every method adds noise, not security.
Layer 4: the ownership check nobody hands you
A role says what kind of user someone is. It says nothing about which orders they may open. Only your domain knows what “owns” means, so we put those rules in one guard component per aggregate, called from every service method, where the rules sit side by side and the author of the next endpoint can see what the neighbours do:
@Component
public class OrderAccessGuard {
private final OrderRepository orders;
public OrderAccessGuard(OrderRepository orders) {
this.orders = orders;
}
/** The owner, or a manager once the order has left DRAFT. */
public Order loadForRead(Long orderId, CurrentUser caller) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new NotFoundException("ORDER_NOT_FOUND")); // 404
if (order.getOwnerAccountId().equals(caller.accountId())) {
return order; // compare ids
}
if (caller.hasRole(SecurityConfig.MANAGER) && !order.isDraft()) {
return order;
}
throw new AccessDeniedException("ORDER_ACCESS_DENIED"); // 403
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderView get(@PathVariable(value = "id") Long id) {
return orderService.get(id); // the service calls the guard; the controller stays thin
}
}
The rules we hold in every project: compare identity by an immutable id, never by e-mail or username. Fail closed: if the data needed to evaluate the check is missing, refuse. Order the checks so the status is honest, 404 before 403, unless revealing that a record exists is itself a leak, in which case return 404 for both and write down why. Re-check on every mutation, not only at creation. And let reads and writes give different answers: refusing to show someone a record they still own strands it, while refusing to let them change it may be exactly right. The exception messages are stable codes, never a value: handlers log those messages.
Identity: key on (issuer, subject), never on e-mail
Business code should not read Authentication or JWT claims. We resolve the caller once, into our own account row, and pass a small CurrentUser around. The account’s key is the pair (iss, sub). E-mail is mutable, can be reassigned to a different person, can exist in two realms at once, and its uniqueness depends on identity-provider settings outside our control. Systems that key on it find out in production, when a rename silently transfers ownership of records. E-mail stays a display attribute.
CREATE TABLE user_account (
id NUMBER(19) GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
issuer VARCHAR2(255) NOT NULL,
subject VARCHAR2(255) NOT NULL,
email VARCHAR2(320),
CONSTRAINT user_account_iss_sub_uk UNIQUE (issuer, subject)
);
@Service
public class CurrentUserService {
private final UserAccountRepository accounts;
private final UserAccountProvisioner provisioner; // provision() runs in REQUIRES_NEW
public CurrentUserService(UserAccountRepository accounts, UserAccountProvisioner provisioner) {
this.accounts = accounts;
this.provisioner = provisioner;
}
public CurrentUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken token)) {
throw new NoActorException(); // a write with no actor fails, never defaults
}
String issuer = token.getToken().getClaimAsString(JwtClaimNames.ISS);
String subject = token.getToken().getSubject();
UserAccount account = accounts.findByIssuerAndSubject(issuer, subject)
.orElseGet(() -> provisionOrReread(issuer, subject));
return new CurrentUser(account.getId(), roleNames(auth));
}
private UserAccount provisionOrReread(String issuer, String subject) {
try {
return provisioner.provision(issuer, subject); // saveAndFlush in its own transaction
} catch (DataIntegrityViolationException lostRace) {
// two first requests raced; the unique key let one win, so use its row
return accounts.findByIssuerAndSubject(issuer, subject).orElseThrow();
}
}
}
This is just-in-time provisioning: the first request from a new (iss, sub) creates the row, so there is no user-import job and no pre-registration step. In production we add a per-request memo and a small bounded cache, so the common case costs no database work. Scheduled jobs and queue listeners have no token; they run inside an explicit system-actor context rather than a “maybe there is no user” branch, and JPA auditing stamps created_by / updated_by from CurrentUser.accountId(). The account id is also what a log line names. The token’s sub and the e-mail stay out of logs.
More than one issuer: route on iss
Sometimes one artifact has to trust two realms, for example when the same jar is deployed behind an external perimeter for customers and an internal one for staff, and cannot know at build time which. Spring Boot’s single issuer-uri property no longer fits, so we build one authentication provider per realm and pick one per request from the token’s iss:
@Bean
public AuthenticationManagerResolver<HttpServletRequest> authManagerResolver(
JwtRealmProperties props, JwtAuthenticationConverter converter) {
if (props.getRealms().isEmpty()) {
throw new IllegalStateException("app.security.jwt.realms must not be empty");
}
Map<String, AuthenticationManager> managers = new LinkedHashMap<>(); // index 0 is the fallback
for (JwtRealmProperties.Realm realm : props.getRealms()) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(realm.getJwkUri()).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(realm.getIssuerUri()));
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder);
provider.setJwtAuthenticationConverter(converter);
managers.put(realm.getIssuerUri(), provider::authenticate);
}
AuthenticationManager fallback = managers.values().iterator().next();
return request -> {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try { // UNVERIFIED parse: used only to choose which verifier runs
String iss = JWTParser.parse(header.substring(7).trim()).getJWTClaimsSet().getIssuer();
AuthenticationManager match = iss == null ? null : managers.get(iss);
if (match != null) {
return match;
}
} catch (ParseException notAJwt) {
// fall through: the fallback realm rejects it with a clean 401
}
}
return fallback;
};
}
.oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authManagerResolver))
app.security.jwt.realms[0].issuer-uri=${OIDC_ISSUER_URI}
app.security.jwt.realms[0].jwk-uri=${OIDC_JWK_SET_URI}
app.security.jwt.realms[1].issuer-uri=${OIDC_ISSUER_URI_2}
app.security.jwt.realms[1].jwk-uri=${OIDC_JWK_SET_URI_2}
Reading iss before the signature is verified looks wrong in a code review, and it is worth understanding why it is safe. The unverified claim does exactly one thing: it selects which key set the token will be verified against. A forged token that names realm B but was not signed by realm B’s key fails verification at realm B’s provider. A missing, unparseable or unknown issuer falls back to realm 0, which then rejects it with an ordinary 401, not a routing error. Every provider still validates the issuer claim itself, so the routing cannot be used to slip a token past the check. What would not be safe is reading sub, roles or anything else before verification.
Spring Security also ships JwtIssuerAuthenticationManagerResolver, which does the same routing out of the box from a list of trusted issuers using OIDC discovery. We wrote our own because we wanted explicit JWKS URIs (no discovery round-trip) and our own claims converter on every provider. Either way, both decoders are lazy: NimbusJwtDecoder.withJwkSetUri(...).build() fetches no keys at startup, so an unreachable Keycloak produces failing requests that health checks can see, not a service that will not boot.
Which realms a deployable lists is a security decision. An internal-only service gets one realm; do not add a second “for parity”.
Audience: not validated by default
Spring Security checks signature, issuer and expiry out of the box. It does not check aud. So a token minted for a different client in the same realm authenticates against your API, and a leaked token from a low-value client becomes a credential for your high-value API. We enable the audience check on every resource server; if for some reason you cannot, write down why:
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains("orders-api"))));
With the single-issuer auto-configuration, the property spring.security.oauth2.resourceserver.jwt.audiences does the same. On the Keycloak side, the client needs an audience mapper, or aud will not contain your API at all.
Why we do not build a dev auth bypass
The shortcut is always proposed: a @Profile("dev") filter that injects a fake principal, so local work needs no Keycloak. We do not build it, for three reasons. A fabricated principal is not an actor: it resolves no (iss, sub), provisions no account row and carries no verified claims, so every identity code path grows a “maybe there is no real user” branch. It hides exactly the failures you will meet on the first real environment: an untrusted TLS chain to the JWKS, a wrong issuer, a role mapped on the wrong side. And it is one misconfigured profile away from a production authentication bypass. Security configuration is unconditional in every profile; profiles govern logging and local settings only. Local work needs a reachable Keycloak, a test user and a trusted TLS chain, which is precisely the cost of dev/prod parity.
Pitfalls
- Forgetting the
ROLE_prefix produces a403for everyone, including admins, and it looks like a Keycloak problem. Compare the token’s role strings to your constants character for character. - A Keycloak group is not a role. Only roles reach
realm_access.roles, either assigned directly or through the group’s role mappings. A user in a group with no role mappings gets a token with no roles. - In any design like this, the coarse gate is the only check left for an endpoint that skips layer 4.
hasAnyRole(A, B, C)lets all three roles reach every endpoint; real restriction depends on the guard being called. That is why every new endpoint is reviewed for its ownership check. - Comparing ownership by e-mail works until someone changes theirs, or an address is reused. Compare account ids.
- A JWT cannot be revoked. It is valid until
exp, even after the user is disabled. Token lifetime is your revocation granularity: keep it short and let clients refresh. - A separate management port bypasses the filter chain. Listing
/actuator/**as public is harmless there, but it is not what protects the port; a firewall is.
Conclusion
Spring Security gives you layers 1 to 3 almost for free. The work that decides whether the API is actually secure is the work the framework cannot do: an ownership guard per aggregate, an identity keyed on (iss, sub) rather than on whatever attribute happens to look unique, and a written answer for the defaults you would otherwise inherit, audience above all. In our next post we cover the other side: calling such an API from Postman, and telling a 401 from a 403 when it refuses.
Cheers!
Related sources
- Spring Security reference: OAuth 2.0 Resource Server JWT
- Spring Security reference: OAuth 2.0 Resource Server Multi-tenancy
- Spring Security reference: Method Security
- Keycloak Server Administration Guide (roles, groups, audience mappers)
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens
- OWASP API Security Top 10: Broken Object Level Authorization
- A Spring Boot resource server on Keycloak, done right: roles, ownership checks and identity - November 13, 2026
- GDPR-compliant logging in Spring Boot, part 3: the personal-data leaks outside your own code - October 19, 2026
- GDPR-compliant logging in Spring Boot, part 2: a build-time test that fails on personal data in log statements - October 19, 2026

