Securing a Spring Boot REST API with Spring Security and JWT
Protect a stateless REST API with Spring Security's OAuth2 resource server support: validate JWTs, map roles, secure methods, and test it all with MockMvc.
Stateless APIs and JSON Web Tokens
A traditional web app keeps the logged-in user in a server-side session. A REST API consumed by mobile apps, single page applications, or other services usually works differently: every request carries a token, and the API validates it without storing any session.
JSON Web Tokens (JWTs) are the most common format for these tokens. A JWT is signed by an authorization server (Keycloak, Auth0, Okta, Microsoft Entra ID, AWS Cognito, and so on). Your Spring Boot app acts as a resource server: it never handles passwords, it only checks that each token is genuine, not expired, and grants the required permissions.
Spring Security has first-class support for this role. The examples target Spring Boot 3.x or later with Java 17+.
Dependencies
Add the OAuth2 Resource Server dependency, which also brings in Spring Security:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Pointing the app at your authorization server
In many cases one property is all you need:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/shop
At startup, Spring Boot uses the issuer's OpenID Connect discovery document to find the public keys (JWK Set). For every request it then verifies the token signature, checks that the iss claim matches, and rejects expired tokens. Keys are cached and refreshed automatically when the authorization server rotates them.
If your provider does not support discovery, configure the key set URL directly with jwk-set-uri instead.
Defining the security rules
Spring Security is configured with a SecurityFilterChain bean:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
Reading the rules from top to bottom:
- Health checks and reading the product catalog are public.
- Everything under
/api/adminrequires theADMINrole. - Any other request needs a valid token.
Rules are evaluated in order and the first match wins, so put specific matchers before general ones.
Why disable CSRF? CSRF attacks abuse cookies that browsers send automatically. A stateless API that only accepts tokens in the Authorization header is not vulnerable in the same way. If your API relies on cookies, keep CSRF protection enabled.
Mapping token claims to roles
By default, Spring Security turns the scope (or scp) claim into authorities prefixed with SCOPE_, such as SCOPE_orders.read. Many identity providers put roles in a different claim. Suppose your tokens look like this:
{
"sub": "8f2c1e",
"preferred_username": "putu",
"roles": ["USER", "ADMIN"],
"exp": 1790000000
}
Tell Spring Security where to find the roles with a JwtAuthenticationConverter bean:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
converter.setPrincipalClaimName("preferred_username");
return converter;
}
Now hasRole("ADMIN") matches tokens whose roles claim contains ADMIN. Keycloak stores realm roles under realm_access.roles, which is a nested claim; in that case write a small custom Converter<Jwt, Collection<GrantedAuthority>> that reads the nested map.
Method-level security
URL rules are great for coarse access control. For business rules, use @PreAuthorize, enabled by @EnableMethodSecurity above:
@Service
class OrderService {
@PreAuthorize("hasRole('ADMIN')")
void cancelAnyOrder(long orderId) { /* ... */ }
@PreAuthorize("#customerId == authentication.name or hasRole('ADMIN')")
List<Order> ordersFor(String customerId) { /* ... */ }
}
The second rule lets users read only their own orders, while administrators can read everyone's.
Accessing the current user
Inject the validated token into a controller method:
@GetMapping("/api/me")
Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"username", jwt.getClaimAsString("preferred_username"),
"roles", jwt.getClaimAsStringList("roles"),
"expiresAt", jwt.getExpiresAt());
}
Validating the audience
An issuer-uri check proves the token came from your authorization server, but that server may issue tokens for many applications. Verify that the token was meant for this API by checking the aud claim:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/shop
audiences: shop-api
Without this check, a token issued for another app in the same realm could be replayed against your API.
What a client sends
Clients put the token in the Authorization header:
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
https://api.example.com/api/orders
Missing or invalid tokens receive 401 Unauthorized with a WWW-Authenticate: Bearer header. Valid tokens without the required role receive 403 Forbidden.
Testing secured endpoints
Spring Security's test support can fake a JWT without a running authorization server. Add spring-security-test and use the jwt() request post-processor:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerTest {
@Autowired
MockMvc mvc;
@Test
void rejectsAnonymousRequests() throws Exception {
mvc.perform(get("/api/admin/reports")).andExpect(status().isUnauthorized());
}
@Test
void rejectsUsersWithoutAdminRole() throws Exception {
mvc.perform(get("/api/admin/reports")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test
void allowsAdmins() throws Exception {
mvc.perform(get("/api/admin/reports")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
}
Testing the negative cases (401 and 403) is just as important as the happy path: they prove that your rules actually protect something.
Security checklist
- Always serve the API over HTTPS; a bearer token is as good as a password while it is valid.
- Keep access tokens short-lived (minutes, not days) and let clients use refresh tokens.
- Validate the audience as well as the issuer.
- Never log full tokens.
- Prefer an established identity provider over issuing your own tokens. Writing a secure login, token signing, and key rotation system is far harder than it looks.
Exercise
Add an endpoint DELETE /api/orders/{id} that customers can call for their own orders and administrators can call for any order. Implement the rule with @PreAuthorize, then write three MockMvc tests: the owner succeeds, another customer gets 403, and an admin succeeds.
