Testing Security
Prove that your security rules hold: 401 without credentials, 403 with the wrong role or scope, and success for the right caller.
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
mvc.get().uri("/orders").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read")))
@WithMockUser(roles = "ADMIN")
mvc.post().uri("/form").with(csrf())Security rules are code, and a typo in a matcher opens an endpoint to everyone. Tests are the only reliable way to know that DELETE /orders/{id} really needs an admin. For each protected endpoint, test three callers: nobody (expect 401), the wrong user (expect 403) and the right user (expect success).
The tools
Add spring-boot-starter-security-test. It brings spring-security-test and makes MockMvc run the security filter chain. You then choose who is calling:
@WithMockUseron a test method or class sets an authenticated user with given roles or authorities, for example@WithMockUser(roles = "ADMIN").jwt()fromSecurityMockMvcRequestPostProcessorsauthenticates one request as a resource server would after validating a token, with the claims and authorities you choose. No real token is signed or decoded.user("ada").roles("USER")andhttpBasic(...)for per-request users.csrf()adds a valid CSRF token for session-based applications.
Load your real rules
In a @WebMvcTest, Spring Boot's security auto-configuration is applied, but your @Configuration class that defines the SecurityFilterChain is not scanned by the slice. Without it you are testing Spring Boot's defaults, not your rules. Add @Import(SecurityConfig.class). For a resource server, the chain also needs a JwtDecoder bean; in a slice test, supply it with @MockitoBean JwtDecoder, since jwt() bypasses decoding anyway.
401 and 403 mean different things
401 means the caller is not authenticated; 403 means they are, but are not allowed. Clients react differently: a 401 should send the user to log in again, a 403 should not. Assert the exact status, not just "4xx".
Mistakes
- Testing only the happy path with an admin user. The bug that matters is a normal user getting in.
- Disabling security in tests through a test profile. Every other test then runs against a configuration production never uses.
- Forgetting
SCOPE_: with the default converter, a JWT scopeorders:writebecomes the authoritySCOPE_orders:write. - A 403 that is really CSRF. In a session-based app a
POSTwithout a token is rejected with 403 before your rules run. Addcsrf().
Example
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
record OrderLine(String sku, int quantity) {
}
class SecurityConfig {
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("SCOPE_orders:read")
.requestMatchers(HttpMethod.POST, "/orders").hasAuthority("SCOPE_orders:write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
}
("/orders")
class OrderController {
List<OrderLine> list() {
return List.of(new OrderLine("SKU-1", 2));
}
OrderLine create( OrderLine line) {
return line;
}
}
(OrderController.class)
(SecurityConfig.class)
class OrderSecurityTest {
MockMvcTester mvc;
JwtDecoder jwtDecoder;
void rejectsAnonymousCallersWith401() {
assertThat(mvc.get().uri("/orders")).hasStatus(HttpStatus.UNAUTHORIZED);
}
void allowsReadingWithTheReadScope() {
assertThat(mvc.get().uri("/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
.hasStatusOk();
}
void forbidsWritingWithOnlyTheReadScope() {
assertThat(mvc.post().uri("/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read")))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku": "SKU-1", "quantity": 1}
"""))
.hasStatus(HttpStatus.FORBIDDEN);
}
void allowsWritingWithTheWriteScope() {
assertThat(mvc.post().uri("/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:write")))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku": "SKU-1", "quantity": 1}
"""))
.hasStatusOk();
}
}
When to use it
- Prove that a token with only the orders:read scope cannot create orders, and that the response is 403, not 401.
- Check that the admin reports endpoint stays closed to ordinary users after someone reorders the request matchers.
- Test a multi-tenant controller that reads the tenant claim from the JWT, without running an identity provider.
More examples
Role checks with @WithMockUser
<code>@WithMockUser(roles = "ADMIN")</code> creates the authority <code>ROLE_ADMIN</code>, which is what <code>hasRole("ADMIN")</code> checks. Use <code>authorities = ...</code> instead when your rules use <code>hasAuthority</code> with names that have no <code>ROLE_</code> prefix. The anonymous test matters as much as the others: with HTTP Basic configured, the answer is 401 with a <code>WWW-Authenticate</code> header, not a redirect to a login page.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Configuration
class AdminSecurityConfig {
@Bean
SecurityFilterChain admin(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
@RestController
class ReportController {
@GetMapping("/admin/reports/daily")
String daily() {
return "ok";
}
}
@WebMvcTest(ReportController.class)
@Import(AdminSecurityConfig.class)
class ReportSecurityTest {
@Autowired
MockMvcTester mvc;
@Test
void anonymousCallersMustAuthenticate() {
assertThat(mvc.get().uri("/admin/reports/daily")).hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser(roles = "USER")
void ordinaryUsersAreForbidden() {
assertThat(mvc.get().uri("/admin/reports/daily")).hasStatus(HttpStatus.FORBIDDEN);
}
@Test
@WithMockUser(username = "ada", roles = "ADMIN")
void adminsCanReadReports() {
assertThat(mvc.get().uri("/admin/reports/daily")).hasStatusOk().hasBodyTextEqualTo("ok");
}
}
Claims from the token reach the controller
<code>jwt().jwt(...)</code> customises the token's claims, so you can test code that reads the subject, a tenant or any other claim without a real identity provider. The post-processor puts a <code>JwtAuthenticationToken</code> into the security context, which is what <code>@AuthenticationPrincipal Jwt</code> resolves in production too. No security configuration is imported here, so the test runs with Spring Boot's default rules, which only require an authenticated caller.
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
record Me(String userId, String tenant) {
}
@RestController
class MeController {
@GetMapping("/me")
Me me(@AuthenticationPrincipal Jwt jwt) {
return new Me(jwt.getSubject(), jwt.getClaimAsString("tenant"));
}
}
@WebMvcTest(MeController.class)
class MeControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
JwtDecoder jwtDecoder;
@Test
void readsTheSubjectAndTenantFromTheToken() {
assertThat(mvc.get().uri("/me")
.with(jwt().jwt(token -> token.subject("user-42").claim("tenant", "acme"))))
.hasStatusOk()
.bodyJson()
.isLenientlyEqualTo("""
{"userId": "user-42", "tenant": "acme"}
""");
}
}
CSRF in a session-based application
With Spring Boot's default security, CSRF protection is on, so an authenticated <code>POST</code> without a token is refused with 403. The first test proves the protection is active; the second adds <code>csrf()</code> to show the endpoint itself works. If a test returns 403 and you cannot see why, check for a missing <code>csrf()</code> before you debug your authorisation rules.
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ProfileController {
@PostMapping("/profile/name")
String rename(@RequestParam String name) {
return name.strip();
}
}
@WebMvcTest(ProfileController.class)
class ProfileCsrfTest {
@Autowired
MockMvcTester mvc;
@Test
void rejectsAPostWithoutACsrfToken() {
assertThat(mvc.post().uri("/profile/name").param("name", "Ada")
.with(user("ada")))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void acceptsAPostWithACsrfToken() {
assertThat(mvc.post().uri("/profile/name").param("name", "Ada")
.with(user("ada"))
.with(csrf()))
.hasStatusOk()
.hasBodyTextEqualTo("Ada");
}
}
Discussion