skip to content

With Spring Security on the classpath, a @WebMvcTest returns 401/403 unexpectedly. Why, and how do you handle it correctly?

level: seniorimportance: should knowfreq 55%

answer

  1. slice applies the security filter chain
  2. 401 = unauth, 403 = wrong role or missing CSRF
  3. .with(csrf()) on POST
  4. @WithMockUser / post-processors
  5. @Import your SecurityFilterChain

basics

~20 s

@WebMvcTest applies your Spring Security filter chain, so unauthenticated MockMvc requests get blocked. Fix it by authenticating the test request — e.g. @WithMockUser or SecurityMockMvcRequestPostProcessors — or by importing your security config, not by disabling security.

solid answer

~40 s

The MVC slice auto-configures Spring Security when it's on the classpath, so the filter chain runs and MockMvc requests without a principal hit 401 (unauthenticated) or 403 (authenticated but unauthorized, including CSRF failures on POST). The right fix is to simulate authentication, not switch security off: annotate with @WithMockUser(roles="...") for a static principal, or use SecurityMockMvcRequestPostProcessors — .with(user("alice").roles("ADMIN")) and .with(csrf()) for state-changing methods. If your rules live in a custom SecurityFilterChain @Configuration that the slice doesn't pick up, @Import it (or an @TestConfiguration test-security config). This lets you actually test authz — asserting that the wrong role gets 403 and the right one 200 — which is a real web-layer concern the slice is meant to cover.

code

java · 21 lines
java
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class) // load real authz rules
class OrderControllerSecurityTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService;

    @Test
    void anonymousIsUnauthorized() throws Exception {
        mockMvc.perform(get("/orders/1"))
               .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void postNeedsCsrf() throws Exception {
        mockMvc.perform(post("/orders").with(csrf())
                .contentType(MediaType.APPLICATION_JSON).content("{}"))
               .andExpect(status().isCreated());
    }
}

go deeper

for a junior

May not know security applies in the slice; can learn @WithMockUser.

for a middle

Knows to authenticate with @WithMockUser and add csrf() for POSTs.

for a senior

Understands 401 vs 403, importing the real SecurityFilterChain, and testing authz positively and negatively.

for a principal

Weighs testing method security vs URL security and enforces a consistent security-testing pattern across the codebase.

## Why it happens `@WebMvcTest` auto-configures **Spring Security** if `spring-security` is on the classpath, applying the security **filter chain** in front of your controllers. Spring Boot's default (in the absence of your own chain) secures all endpoints. So a `MockMvc` request with no authenticated principal is rejected: - **401 Unauthorized** — no/invalid authentication. - **403 Forbidden** — authenticated but lacking the required role/authority, **or** a missing/invalid **CSRF token** on a state-changing request (POST/PUT/DELETE), since CSRF protection is on by default. Candidates who 'fix' this by globally disabling security are hiding a real concern and losing authz test coverage. ## Correct approaches (spring-security-test) Add the `spring-security-test` dependency, then: **1. `@WithMockUser`** — declaratively run the test as a fixed principal: ```java @Test @WithMockUser(username = "alice", roles = {"ADMIN"}) void adminCanAccess() throws Exception { mockMvc.perform(get("/admin")).andExpect(status().isOk()); } ``` Variants: `@WithAnonymousUser`, `@WithUserDetails` (loads via your `UserDetailsService`), and custom `@WithSecurityContext` meta-annotations. **2. Request post-processors** — per-request, more flexible: ```java import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; mockMvc.perform(post("/orders") .with(user("alice").roles("USER")) .with(csrf()) // supply a valid CSRF token .contentType(APPLICATION_JSON).content(json)) .andExpect(status().isCreated()); ``` `csrf()` is the usual reason a POST that 'should work' returns 403. There is also `jwt()` / `oauth2Login()` for token-based setups. ## Making your real rules load A slice picks up `@ControllerAdvice`, filters and security auto-config, but a **custom `SecurityFilterChain`** defined in a regular `@Configuration` class in your app **may not be imported automatically**. To test your actual authorization rules, `@Import(SecurityConfig.class)` (or a dedicated `@TestConfiguration`). Then negative tests assert real behavior: ```java @Test @WithMockUser(roles = "USER") void userForbiddenFromAdmin() throws Exception { mockMvc.perform(get("/admin")).andExpect(status().isForbidden()); } ``` ## Gotchas - Forgetting `.with(csrf())` → mysterious 403 on POST/PUT/DELETE. - `roles("ADMIN")` maps to authority `ROLE_ADMIN`; using `authorities("ADMIN")` does **not** add the `ROLE_` prefix. - Method-level security (`@PreAuthorize`) requires `@EnableMethodSecurity` config to be present in the slice — import the relevant config, since a bare slice won't necessarily enable it. - Disabling security in tests (`@AutoConfigureMockMvc(addFilters = false)`) is occasionally valid to isolate non-security behavior, but it means you are **not** testing authz.

  • Your POST test returns 403 even with @WithMockUser. What's the likely cause?
    Missing CSRF token — CSRF protection is enabled by default, so state-changing requests need .with(csrf()) (or CSRF disabled in config). Authentication alone isn't enough.
  • How do you test a @PreAuthorize rule in a @WebMvcTest?
    Ensure method security is enabled in the loaded config (@EnableMethodSecurity, typically via @Import of your security config), then run the test as principals with and without the required authority and assert 200 vs 403.

saying these in an interview costs you the question

  • Disabling security with addFilters=false as the default fix
  • Thinking security isn't active in a slice
  • Confusing 401 (unauthenticated) with 403 (forbidden/CSRF)
  • Assuming roles("ADMIN") equals authority "ADMIN" without the ROLE_ prefix

context