Testing Security

Prove that your security rules hold: 401 without credentials, 403 with the wrong role or scope, and success for the right caller.

Syntax@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:

  • @WithMockUser on a test method or class sets an authenticated user with given roles or authorities, for example @WithMockUser(roles = "ADMIN").
  • jwt() from SecurityMockMvcRequestPostProcessors authenticates 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") and httpBasic(...) 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 scope orders:write becomes the authority SCOPE_orders:write.
  • A 403 that is really CSRF. In a session-based app a POST without a token is rejected with 403 before your rules run. Add csrf().

Example

Example · java
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) {
}

@Configuration
class SecurityConfig {

    @Bean
    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();
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    @GetMapping
    List<OrderLine> list() {
        return List.of(new OrderLine("SKU-1", 2));
    }

    @PostMapping
    OrderLine create(@RequestBody OrderLine line) {
        return line;
    }
}

@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderSecurityTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    JwtDecoder jwtDecoder;

    @Test
    void rejectsAnonymousCallersWith401() {
        assertThat(mvc.get().uri("/orders")).hasStatus(HttpStatus.UNAUTHORIZED);
    }

    @Test
    void allowsReadingWithTheReadScope() {
        assertThat(mvc.get().uri("/orders")
                .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
                .hasStatusOk();
    }

    @Test
    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);
    }

    @Test
    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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.