Spring Boot OAuth2 + JWT: End-to-End Zero-Trust API Security
Zero-trust API security means every request is validated independently — no session state, no “trusted network” assumptions. A JWT bearer token is issued by an authorisation server, signed cryptographically, and validated on every API call. The API never calls back to the authorisation server during validation; it verifies the token’s signature locally.
This guide covers the complete setup: dependencies, resource server configuration, token validation (both symmetric and asymmetric), extracting claims, role-based access control, method-level security, and the Spring Security 7 changes that break existing setups.
How JWT Authentication Works
sequenceDiagram
participant Client
participant AuthServer as Auth Server\n(Keycloak/Auth0/custom)
participant API as Spring Boot API
Client->>AuthServer: POST /token {username, password}
AuthServer-->>Client: JWT {header.payload.signature}
Client->>API: GET /orders Authorization: Bearer {JWT}
API->>API: Verify signature with public key
API->>API: Check expiry, issuer, audience
API->>API: Extract roles from claims
API-->>Client: 200 OK {order data}
Note over API: No call back to Auth Server\nValidation is local and fast
The JWT contains three parts:
- Header: algorithm used for signing (
RS256,HS256, etc.) - Payload: claims (subject, expiry, issuer, roles, custom data)
- Signature: cryptographic signature — tamper-proof
Spring Boot’s OAuth2 resource server starter handles all of this automatically once configured.
Dependencies
<!-- 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>
The oauth2-resource-server starter includes the Nimbus JOSE + JWT library for token parsing and validation.
Option 1: Validate Against an OIDC Provider (Recommended)
If you use Keycloak, Auth0, Okta, Google, or any OIDC-compliant provider, Spring Boot can automatically fetch the public key and validate tokens with a single property:
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-auth-server.com/realms/myrealm
Spring Boot fetches the provider’s JWKS (public key set) from {issuer-uri}/.well-known/openid-configuration, caches it, and uses it to validate every token. When the provider rotates keys, Spring Boot fetches the new key automatically.
The security configuration is minimal:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable()); // safe for stateless JWT APIs
return http.build();
}
}
Option 2: Validate with a Symmetric Key (HMAC)
For simpler setups where your application both issues and validates tokens (no separate auth server), use a shared secret:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${jwt.secret}")
private String jwtSecret;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
// Secret must be at least 256 bits (32 characters) for HS256
SecretKeySpec key = new SecretKeySpec(jwtSecret.getBytes(), "HmacSHA256");
return NimbusJwtDecoder.withSecretKey(key).build();
}
@Bean
public JwtEncoder jwtEncoder() {
SecretKeySpec key = new SecretKeySpec(jwtSecret.getBytes(), "HmacSHA256");
JWKSource<SecurityContext> jwkSource = new ImmutableSecret<>(key);
return new NimbusJwtEncoder(jwkSource);
}
}
Issuing a token:
@Service
public class TokenService {
private final JwtEncoder jwtEncoder;
public TokenService(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}
public String generateToken(UserDetails user) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("my-app")
.issuedAt(now)
.expiresAt(now.plus(1, ChronoUnit.HOURS))
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()))
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
Option 3: Validate with RSA Public Key (Asymmetric)
For production systems where the auth server uses RS256 (RSA), provide the public key directly:
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:public-key.pem
Or configure programmatically:
@Bean
public JwtDecoder jwtDecoder() throws Exception {
RSAPublicKey publicKey = (RSAPublicKey) KeyFactory
.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(rsaPublicKey)
));
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
Extracting Claims in Controllers
Once a request is authenticated, access the JWT claims via @AuthenticationPrincipal:
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/profile")
public UserProfile getProfile(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
List<String> roles = jwt.getClaimAsStringList("roles");
return userService.getProfile(userId);
}
@GetMapping("/orders")
public List<Order> getOrders(@AuthenticationPrincipal Jwt jwt) {
// Subject is typically the user ID
return orderService.findByUserId(jwt.getSubject());
}
}
Role-Based Access Control
HTTP-Level Role Checks
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MODERATOR")
.anyRequest().authenticated()
);
Custom JWT Claim Mapping
By default, Spring Security looks for roles in the scope or scp claim. If your JWT stores roles in a different claim (e.g., roles or realm_access.roles in Keycloak), configure a custom converter:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles"); // read from "roles" claim
converter.setAuthorityPrefix("ROLE_"); // prefix for hasRole() checks
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(converter);
return jwtConverter;
}
}
For Keycloak, which nests roles in realm_access.roles:
@Bean
public JwtAuthenticationConverter keycloakConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) return Collections.emptyList();
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles == null) return Collections.emptyList();
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
});
return converter;
}
Method-Level Security
Enable method security to enforce access control at the service layer:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig { ... }
@Service
public class OrderService {
// Only ADMIN can see all orders
@PreAuthorize("hasRole('ADMIN')")
public List<Order> findAll() {
return orderRepository.findAll();
}
// Users can only see their own orders; ADMIN can see any
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.subject")
public List<Order> findByUser(String userId) {
return orderRepository.findByUserId(userId);
}
// After fetching, check that the returned object belongs to the caller
@PostAuthorize("returnObject.userId == authentication.principal.subject or hasRole('ADMIN')")
public Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
// Restrict who can cancel orders
@PreAuthorize("hasAnyRole('ADMIN', 'SUPPORT') or #order.userId == authentication.principal.subject")
public void cancel(Order order) {
order.cancel();
orderRepository.save(order);
}
}
Method security is enforced regardless of how the method is called — through a web request, from a scheduled job, or from another service. It is the right place for access control logic that needs to apply universally.
Testing Secured Endpoints
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
@WithMockUser(roles = "USER")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
given(orderService.findByUser("testuser")).willReturn(List.of(new Order()));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
@Test
void shouldReturn401ForUnauthenticatedRequest() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void shouldReturn403WhenUserAccessesAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isForbidden());
}
}
For testing with a real JWT token:
@Test
void shouldAcceptValidJwtToken() throws Exception {
String token = generateTestToken("user-123", List.of("ROLE_USER"));
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
private String generateTestToken(String subject, List<String> roles) {
// Use your JwtEncoder bean to generate a token with test claims
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(subject)
.issuedAt(Instant.now())
.expiresAt(Instant.now().plus(1, ChronoUnit.HOURS))
.claim("roles", roles)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
Spring Security 7: What Changed
Three changes affect JWT setups when upgrading to Spring Boot 4 / Spring Security 7.
1. authorizeRequests() is removed:
// Old — compile error in Security 7
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN");
// New — required
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
);
2. CSRF enabled by default for REST APIs:
In Security 7, CSRF protection is enabled for all requests including REST APIs. Stateless JWT APIs must explicitly disable it:
http.csrf(csrf -> csrf.disable());
This is safe when you use JWT bearer tokens (not cookies for authentication). CSRF attacks require the browser to automatically send credentials — bearer tokens in the Authorization header are not sent automatically by browsers, so CSRF is not applicable.
3. antMatchers() removed:
Replace all antMatchers() with requestMatchers(). The behaviour is identical; the method was renamed.
CORS Configuration for Frontend Clients
If your frontend (React, Vue, Angular) runs on a different origin from your API, configure CORS:
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.example.com",
"http://localhost:3000" // development only
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
// Apply in the security filter chain
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
Complete Security Configuration (Production-Ready)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Permit public endpoints
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN") // protect other actuator endpoints
.anyRequest().authenticated()
)
// JWT resource server
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
// Stateless sessions
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Disable CSRF for bearer token APIs
.csrf(csrf -> csrf.disable())
// CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Security headers
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(Customizer.withDefaults())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation(issuerUri);
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
Quick Reference
| Goal | Configuration |
|---|---|
| Validate against OIDC provider | spring.security.oauth2.resourceserver.jwt.issuer-uri |
| Validate with symmetric key | NimbusJwtDecoder.withSecretKey(key) |
| Validate with RSA public key | spring.security.oauth2.resourceserver.jwt.public-key-location |
| Extract claims in controller | @AuthenticationPrincipal Jwt jwt |
| Role from custom JWT claim | Custom JwtAuthenticationConverter |
| Method-level security | @EnableMethodSecurity + @PreAuthorize |
| Disable CSRF (stateless API) | csrf -> csrf.disable() |
| Testing secured endpoints | @WithMockUser(roles = "ADMIN") |
The full picture: your auth server issues signed JWTs, your Spring Boot API validates them locally using the public key, extracts roles from the token claims, and enforces access at both the HTTP layer and the method layer. No shared session state, no calls back to the auth server per request — every validation is local and fast.
