skip to content

A team's MockMvc POST tests intermittently fail with 403 despite @WithMockUser, and their 'anonymous access returns 401' test passes even after they broke config. What test-support pitfalls explain this?

level: principalimportance: should knowfreq 35%

answer

  1. 403 on POST = missing .with(csrf())
  2. false-green = filter chain not wired
  3. apply(springSecurity()) / @AutoConfigureMockMvc
  4. assert authenticated()/unauthenticated() as canary
  5. jwt() skips decode — separate decode test

basics

~20 s

The 403 comes from missing CSRF tokens on mutating requests — @WithMockUser authenticates but doesn't add one; they must use .with(csrf()). The false-passing test likely tests without wiring Spring Security into MockMvc (no apply(springSecurity())) or a @WebMvcTest with mocked/absent filter chain, so security isn't actually enforced.

solid answer

~40 s

Two classic spring-security-test traps. (1) CSRF: @WithMockUser only seeds authentication; Spring Security's default CSRF protection still rejects POST/PUT/PATCH/DELETE with 403 unless you attach SecurityMockMvcRequestPostProcessors.csrf(). Intermittency usually means only some mutating tests add it. (2) Security not actually wired: a bare standaloneSetup MockMvc, or a MockMvc built without .apply(springSecurity()), or a sliced @WebMvcTest whose SecurityFilterChain/UserDetailsService isn't imported, means the security filters never run — so authorization 'passes' regardless of config, and negative tests give false confidence. The fix: build MockMvc from the web application context with SecurityMockMvcConfigurers.springSecurity(), ensure the real SecurityFilterChain is in the test context, add csrf() to mutating requests, and assert with SecurityMockMvcResultMatchers.authenticated()/unauthenticated() to catch a silently-disabled chain.

code

java · 29 lines
java
@SpringBootTest
@AutoConfigureMockMvc   // loads real SecurityFilterChain + applies springSecurity()
class SecurityWiringTest {

  @Autowired MockMvc mvc;

  @Test // canary: proves the chain is actually enforcing
  void anonymous_is_denied() throws Exception {
    mvc.perform(get("/api/admin/reports"))
       .andExpect(unauthenticated())
       .andExpect(status().isUnauthorized());
  }

  @Test
  @WithMockUser(roles = "ADMIN")
  void admin_can_post_with_csrf() throws Exception {
    mvc.perform(post("/api/admin/reports").with(csrf())
          .contentType(APPLICATION_JSON).content("{}"))
       .andExpect(status().isCreated());
  }

  @Test
  @WithMockUser(roles = "ADMIN")
  void missing_csrf_is_403() throws Exception {
    mvc.perform(post("/api/admin/reports")
          .contentType(APPLICATION_JSON).content("{}"))
       .andExpect(status().isForbidden()); // documents the CSRF requirement
  }
}

go deeper

for a junior

Know missing csrf() causes 403 on POST.

for a middle

Explain both CSRF and that standaloneSetup/@WebMvcTest may not enforce real security.

for a senior

Wire springSecurity()/real chain, use authenticated()/unauthenticated() matchers, and separate jwt() authz tests from decode tests.

for a principal

Set org-wide testing policy that prevents false-green authorization tests: full-context authz tests, canary denials, authority sourcing via @WithUserDetails, and explicit decode-path coverage.

## Symptom 1: intermittent 403 on POST **Cause:** `@WithMockUser` (and `user(...)`) establish *authentication*, but Spring Security's default configuration enables **CSRF protection**. Any state-changing method (POST/PUT/PATCH/DELETE) requires a valid CSRF token; without one the `CsrfFilter` returns **403**. `@WithMockUser` does NOT add a token. **Why intermittent:** whichever mutating tests happen to include `.with(csrf())` pass; the ones that forgot it fail. It looks flaky but is deterministic per-test. **Fix:** add `SecurityMockMvcRequestPostProcessors.csrf()` to every mutating request (or `csrf().asHeader()`), or in dedicated tests confirm the negative path with `csrf().useInvalidToken()`. ## Symptom 2: negative test passes even after breaking prod security **Cause:** the security **filter chain isn't actually in the MockMvc pipeline**, so authorization is never evaluated. Common ways this happens: - MockMvc built via `MockMvcBuilders.standaloneSetup(controller)` — this wires only the controller, no filters, no Spring Security. - MockMvc built from the context but WITHOUT `.apply(SecurityMockMvcConfigurers.springSecurity())` when the filter isn't otherwise registered. - A sliced `@WebMvcTest` where the app's `SecurityFilterChain`/`UserDetailsService`/method-security beans aren't imported (slices don't load arbitrary `@Configuration`), so a permissive default applies or beans are mocked. - Method security not enabled in the slice (`@EnableMethodSecurity` config not imported), so `@PreAuthorize` is inert. In all these, an endpoint you *think* is protected is wide open in the test, so `expectStatus().isUnauthorized()`/`isForbidden()` assertions may accidentally pass for the wrong reason, or a 'should be denied' test never denies. ## Robust setup ```java @Autowired WebApplicationContext ctx; MockMvc mvc = MockMvcBuilders.webAppContextSetup(ctx) .apply(SecurityMockMvcConfigurers.springSecurity()) .build(); ``` or with Spring Boot: `@SpringBootTest` + `@AutoConfigureMockMvc` auto-applies `springSecurity()` and loads the real chain. Prefer this for authz-sensitive tests over `@WebMvcTest` unless you deliberately import the security config. ## Guarding against silent disablement Use `SecurityMockMvcResultMatchers`: ```java mvc.perform(formLogin().user("alice").password("pw")) .andExpect(authenticated().withUsername("alice")); mvc.perform(get("/private")) .andExpect(unauthenticated()); ``` If the chain were absent, `authenticated()`/`unauthenticated()` assertions would fail — turning a silent gap into a red test. Also assert a *known-protected* endpoint returns 401/403 for anonymous as a canary. ## Other principal-level pitfalls 1. **Context leakage between tests** — the listener clears the context, but if you manually set `SecurityContextHolder` without cleanup it can bleed; prefer the annotations/post-processors. 2. **`jwt()`/`mockJwt()` bypass decoding** — passing tests don't prove your `JwtDecoder`/JWKS/issuer validation works; add a separate decode-path test (e.g. mock issuer) or these bugs reach prod. 3. **Authority prefix drift** — hand-written `roles`/`authorities` in `@WithMockUser` can diverge from production mapping; `@WithUserDetails` avoids this by sourcing authorities from the real `UserDetailsService`. 4. **CSRF disabled in prod but on in test (or vice-versa)** — if prod config disables CSRF (e.g. stateless JWT API), then `.with(csrf())` is unnecessary and a 403 means something else; keep test assumptions aligned with the actual `SecurityFilterChain`. 5. **Ordering with setupBefore** — setup code that reads the user needs `setupBefore = TEST_METHOD` (default seeds before `@BeforeEach`). ## Governance takeaway For authorization-critical code, prefer full-context tests with the real chain, assert with `authenticated()/unauthenticated()`, keep a canary anonymous-denied test, and separate authz-seeding tests (`jwt()`) from token-decode tests. This prevents both false-green negative tests and CSRF-flakiness.

  • Your @WebMvcTest for @PreAuthorize passes for everyone regardless of role. What's wrong?
    The slice likely didn't import your @EnableMethodSecurity configuration or the SecurityFilterChain, so method security isn't active. Import the security config (@Import) or use @SpringBootTest with the real context so @PreAuthorize is actually evaluated.
  • How can you make a silently-disabled security chain fail loudly in tests?
    Assert with SecurityMockMvcResultMatchers.authenticated()/unauthenticated(), and keep a canary test that an anonymous request to a known-protected endpoint returns 401/403. If the chain isn't wired, these assertions fail instead of passing for the wrong reason.
  • When is .with(csrf()) NOT needed?
    When the SecurityFilterChain disables CSRF (common for stateless token-based APIs) or for safe methods (GET/HEAD/OPTIONS). Align the test with the actual config; a 403 on POST in a CSRF-disabled app points to a different problem.

context