Contents
- OAuth2 & OIDC Concepts
- Dependencies & Setup
- JWT Resource Server Configuration
- Extracting Roles from Custom Claims
- Endpoint Security — Scopes & Roles
- Method Security with @PreAuthorize
- Opaque Token Introspection
- OIDC Login for Web Apps
- Testing Secured Endpoints
In OAuth2 there are four roles. The Resource Owner (user) grants access to their data. The Client (your frontend or another service) requests access. The Authorization Server (Keycloak, Auth0) authenticates the user and issues tokens. The Resource Server (your Spring Boot API) validates tokens and serves protected resources.
Your Spring Boot service only plays the Resource Server role — it never handles passwords or issues tokens. It trusts tokens from a configured Authorization Server, validates their signature against the server's public keys (fetched automatically from the JWKS endpoint), and extracts the user's identity and permissions from the token's claims.
| Token type | Validation method | Pros | Cons |
| JWT | Local signature verification (JWKS) | No network call; fast | Cannot revoke before expiry |
| Opaque | Introspection endpoint call | Revocable instantly | Network call per request |
<!-- pom.xml -->
<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>
<!-- For OIDC login (web apps only) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
# application.yml — point to your Authorization Server's OIDC discovery endpoint
spring:
security:
oauth2:
resourceserver:
jwt:
# Spring fetches JWKS keys automatically from this issuer
issuer-uri: https://your-keycloak-host/realms/my-realm
# Alternative: provide the JWKS URI directly
# jwk-set-uri: https://your-keycloak-host/realms/my-realm/protocol/openid-connect/certs
With the issuer URI set, Spring Security auto-configures a filter that validates every incoming Authorization: Bearer <token> header. A minimal security configuration that requires authentication for all endpoints looks like this:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
// Enable JWT Resource Server — validates Bearer tokens automatically
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> {}) // uses issuer-uri from application.yml
)
// REST APIs are stateless — no session needed
.sessionManagement(session -> session
.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
To access the authenticated principal in a controller, inject JwtAuthenticationToken or @AuthenticationPrincipal Jwt jwt:
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"subject", jwt.getSubject(),
"email", jwt.getClaimAsString("email"),
"username", jwt.getClaimAsString("preferred_username"),
"expires", jwt.getExpiresAt()
);
}
}
Authorization Servers embed roles/permissions in custom JWT claims. Keycloak uses realm_access.roles; Auth0 uses a custom namespace claim. Spring Security maps JWT claims to GrantedAuthority objects via a JwtAuthenticationConverter. You must customise this converter to read roles from the right claim.
import org.springframework.core.convert.converter.Converter;
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.*;
import java.util.*;
import java.util.stream.Collectors;
// Custom converter for Keycloak's realm_access.roles claim
public class KeycloakRoleConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// Keycloak puts roles under: { "realm_access": { "roles": ["ADMIN", "USER"] } }
Map<String, Object> realmAccess =
jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}
}
// Wire it into the security config
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRoleConverter());
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(converter))
)
.sessionManagement(s -> s.sessionCreationPolicy(
org.springframework.security.config.http.SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
Use hasAuthority() for OAuth2 scopes (prefixed SCOPE_ by default) and hasRole() for roles (prefixed ROLE_). Mix them freely in authorizeHttpRequests().
http.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/actuator/health", "/api/public/**").permitAll()
// Scope-based — OAuth2 client must have requested "read:products" scope
.requestMatchers(HttpMethod.GET, "/api/products/**")
.hasAuthority("SCOPE_read:products")
.requestMatchers(HttpMethod.POST, "/api/products/**")
.hasAuthority("SCOPE_write:products")
// Role-based (from Keycloak realm roles via our custom converter)
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Require authentication for everything else
.anyRequest().authenticated()
);
OAuth2 scopes (SCOPE_read:products) represent what the client app is allowed to do. Roles (ROLE_ADMIN) represent what the user is allowed to do. Both are modelled as GrantedAuthority in Spring Security — the prefix distinguishes them.
Enable method-level security with @EnableMethodSecurity and then annotate service or controller methods directly. This is cleaner than large authorizeHttpRequests() blocks for fine-grained, domain-level access control.
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig {
// ... filterChain bean
}
// Service with method-level security
import org.springframework.security.access.prepost.PreAuthorize;
@Service
public class OrderService {
// Only users with ADMIN role OR the order's own owner can view it
@PreAuthorize("hasRole('ADMIN') or #jwt.subject == #ownerId")
public Order getOrder(String orderId, String ownerId,
@AuthenticationPrincipal Jwt jwt) {
return orderRepository.findById(orderId).orElseThrow();
}
// Only ADMIN can delete
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(String orderId) {
orderRepository.deleteById(orderId);
}
// Only the token subject (user) can update their own profile
@PreAuthorize("#userId == authentication.token.subject")
public User updateProfile(String userId, UserUpdateRequest req) {
return userRepository.findById(userId)
.map(u -> u.applyUpdate(req))
.map(userRepository::save)
.orElseThrow();
}
}
When your Authorization Server issues opaque (non-JWT) tokens, Spring Security calls the introspection endpoint on every request to validate the token and fetch its attributes. Configure with introspection-uri instead of issuer-uri.
// application.yml
// spring:
// security:
// oauth2:
// resourceserver:
// opaquetoken:
// introspection-uri: https://auth.example.com/oauth2/introspect
// client-id: my-resource-server
// client-secret: ${INTROSPECTION_SECRET}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
// Use opaqueToken() instead of jwt()
.opaqueToken(opaque -> opaque
.introspectionUri("https://auth.example.com/oauth2/introspect")
.introspectionClientCredentials("my-resource-server", "${INTROSPECTION_SECRET}")
)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
Opaque token introspection adds a network round-trip to every authenticated request. Cache the introspection result for the token's lifetime using a CachingOpaqueTokenIntrospector wrapper backed by Caffeine or Redis to avoid hammering the Authorization Server.
For browser-facing apps (not pure APIs), use the OAuth2 Client starter for OIDC login. Spring Security handles the redirect to the Authorization Server, the authorization code exchange, and the session. Add spring-boot-starter-oauth2-client and configure the registration.
# application.yml — OIDC login with Keycloak
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-web-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid,profile,email
authorization-grant-type: authorization_code
provider:
keycloak:
issuer-uri: https://your-keycloak-host/realms/my-realm
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login**", "/error**").permitAll()
.anyRequest().authenticated()
)
// Enable OIDC login — Spring handles the redirect flow
.oauth2Login(login -> login
.defaultSuccessUrl("/dashboard", true)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
);
return http.build();
}
}
// Access OIDC user info in a controller
@GetMapping("/dashboard")
public String dashboard(@AuthenticationPrincipal OidcUser oidcUser, Model model) {
model.addAttribute("name", oidcUser.getFullName());
model.addAttribute("email", oidcUser.getEmail());
return "dashboard";
}
Use @WithMockUser for simple role tests or build a mock JWT with SecurityMockMvcRequestPostProcessors.jwt() for full token-claim testing.
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@Test
void meEndpoint_returnsJwtClaims() throws Exception {
mvc.perform(get("/api/me")
.with(jwt()
.jwt(j -> j
.subject("user-123")
.claim("email", "alice@example.com")
.claim("preferred_username", "alice")
)
))
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject").value("user-123"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void adminEndpoint_forbiddenWithoutAdminRole() throws Exception {
mvc.perform(get("/api/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test
void adminEndpoint_accessibleWithAdminRole() throws Exception {
mvc.perform(get("/api/admin/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
}