Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Testing Spring Security

Writing security tests that catch real gaps: @WithMockUser and @WithUserDetails, MockMvc request post-processors, testing method security, mock JWTs, and the negative tests that matter.

Intermediate6 min readUpdated
On this page

Security code is the code most likely to be wrong in a way tests never notice, because the happy path works. The tests that matter are the ones asserting that something is refused.

Key Takeaways

  • Write negative tests — most vulnerabilities pass every positive test.
  • .with(csrf()) is required for state-changing MockMvc requests.
  • @WithUserDetails exercises your real authority mapping; @WithMockUser does not.
  • Test method security separately — it protects callers a URL test never reaches.
  • Assert on object-level authorisation for every endpoint taking a resource id.

The annotations

SecurityAnnotations.java
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
 
    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;
 
    @Test
    @WithAnonymousUser
    void anonymousCannotListOrders() throws Exception {
        mvc.perform(get("/api/v1/orders"))
           .andExpect(status().isUnauthorized());
    }
 
    @Test
    @WithMockUser(username = "alice", roles = "USER")
    void userCanListTheirOwnOrders() throws Exception {
        mvc.perform(get("/api/v1/orders"))
           .andExpect(status().isOk());
    }
 
    @Test
    @WithMockUser(username = "alice", roles = "USER")
    void userCannotReachAdminEndpoints() throws Exception {
        mvc.perform(get("/api/v1/admin/users"))
           .andExpect(status().isForbidden());
    }
 
    @Test
    @WithMockUser(roles = "USER")
    void postWithoutCsrfIsRejected() throws Exception {
        // Proves CSRF protection is actually active. Without this test, a
        // future .csrf().disable() would break nothing visible.
        mvc.perform(post("/api/v1/orders").contentType(APPLICATION_JSON).content("{}"))
           .andExpect(status().isForbidden());
    }
 
    @Test
    @WithMockUser(roles = "USER")
    void postWithCsrfSucceeds() throws Exception {
        mvc.perform(post("/api/v1/orders")
                .with(csrf())
                .contentType(APPLICATION_JSON)
                .content("""
                    {"customerId":"cus_1","lines":[{"sku":"ABC-1234","quantity":1}]}
                    """))
           .andExpect(status().isCreated());
    }
}

Note the pair of CSRF tests. Testing only the success case means an accidental csrf().disable() still passes — the assertion that a token is required is the one doing the work.

There is a trap to clear before any of this means anything. @WebMvcTest loads the controller and Spring Security's auto-configuration, but not your own SecurityFilterChain if it lives in a @Configuration class outside the slice. The test then runs against Spring Boot's default security instead of yours — which still returns 401 for anonymous requests, so the test passes and proves nothing about the rules you actually wrote. Import the configuration explicitly with @Import(SecurityConfig.class), and make at least one assertion that only your rules could satisfy.

Request post-processors

PostProcessors.java
// A synthetic user, inline rather than as an annotation.
mvc.perform(get("/api/v1/orders").with(user("alice").roles("USER")));
 
// HTTP Basic.
mvc.perform(get("/internal/health").with(httpBasic("svc", "secret")));
 
// A mock JWT for a resource server, with the exact claims your converter reads.
mvc.perform(get("/api/v1/orders").with(jwt().jwt(jwt -> jwt
        .subject("cus_8Fj3kQ")
        .claim("scope", "orders.read")
        .claim("roles", List.of("USER"))
        .claim("tenant_id", "acme"))));
 
// An opaque token.
mvc.perform(get("/api/v1/orders").with(opaqueToken()
        .attributes(a -> a.put("sub", "cus_1"))
        .authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))));
 
// An OIDC login session.
mvc.perform(get("/dashboard").with(oidcLogin()
        .idToken(token -> token.claim("email", "alice@example.com"))));

The jwt() post-processor is worth using rather than a real signed token. It exercises your authorities converter and your authorisation rules without needing a key, an issuer or a JWKS endpoint — which is exactly the part you want under test.

Be clear about what that trades away, though. These post-processors bypass authentication rather than performing it, which is correct for authorisation tests — you are asserting what a principal holding these claims may do. It also means they say nothing about whether your decoder is configured properly, whether the audience is validated, or whether the converter reads the claim your issuer actually emits. Those deserve their own small set of tests against a real or mocked issuer, and they are worth writing once: a misconfigured decoder makes every authorisation test above meaningless, because it would accept a token nobody legitimate issued.

Object-level authorisation

The most common API vulnerability survives because the test suite only ever asks the permitted question.
ObjectLevelAuthorizationTest.java
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderAuthorizationTest {
 
    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Autowired MockMvc mvc;
    @Autowired OrderRepository orders;
 
    private String aliceOrderId;
    private String bobOrderId;
 
    @BeforeEach
    void seed() {
        aliceOrderId = orders.save(orderFor("alice")).id();
        bobOrderId = orders.save(orderFor("bob")).id();
    }
 
    @Test
    void aliceCanReadHerOwnOrder() throws Exception {
        mvc.perform(get("/api/v1/orders/{id}", aliceOrderId)
                .with(jwt().jwt(j -> j.subject("alice"))))
           .andExpect(status().isOk());
    }
 
    @Test
    void aliceCannotReadBobsOrder() throws Exception {
        mvc.perform(get("/api/v1/orders/{id}", bobOrderId)
                .with(jwt().jwt(j -> j.subject("alice"))))
           // 404, not 403 — a 403 confirms the order exists.
           .andExpect(status().isNotFound());
    }
 
    @Test
    void aliceCannotCancelBobsOrder() throws Exception {
        mvc.perform(post("/api/v1/orders/{id}/cancellations", bobOrderId)
                .with(jwt().jwt(j -> j.subject("alice"))).with(csrf()))
           .andExpect(status().isNotFound());
 
        assertThat(orders.findById(bobOrderId).orElseThrow().status())
                .isEqualTo(OrderStatus.PLACED);
    }
}

Write this pair for every endpoint that takes a resource identifier. It is repetitive, and it catches the vulnerability class that appears at the top of the OWASP API list.

Method security

MethodSecurityTest.java
@SpringBootTest
class ExpenseServiceSecurityTest {
 
    @Autowired ExpenseService expenses;
 
    @Test
    @WithMockUser(roles = "USER")
    void userCannotApprove() {
        assertThatThrownBy(() -> expenses.approve(new ExpenseId("e1")))
                .isInstanceOf(AccessDeniedException.class);
    }
 
    @Test
    @WithMockUser(roles = "MANAGER")
    void managerCanApprove() {
        assertThatCode(() -> expenses.approve(new ExpenseId("e1")))
                .doesNotThrowAnyException();
    }
 
    @Test
    void unauthenticatedCallIsDenied() {
        // Covers the message-listener and scheduled-job paths, which no
        // URL-based test reaches.
        assertThatThrownBy(() -> expenses.approve(new ExpenseId("e1")))
                .isInstanceOf(AuthenticationCredentialsNotFoundException.class);
    }
}

That last test is the reason method security exists. A service invoked from a Kafka listener has no HTTP request and no URL rule protecting it, and this is the only place that gap shows up.

The proxy caveat applies to the tests as much as to the production code. @PreAuthorize is enforced by a proxy, so a @SpringBootTest that injects the bean receives the proxy and the check runs — while a plain unit test constructing new ExpenseService(...) receives the bare object, where every annotation is inert. A test written the second way passes identically whether the annotation is present or was deleted last week, which is the worst outcome available to a security test: green, and measuring nothing.

Mass assignment

MassAssignmentTest.java
@Test
void cannotEscalatePrivilegeViaRequestBody() throws Exception {
    mvc.perform(put("/api/v1/users/{id}", aliceId)
            .with(jwt().jwt(j -> j.subject(aliceId))).with(csrf())
            .contentType(APPLICATION_JSON)
            .content("""
                {"displayName":"Alice","role":"ADMIN","creditBalance":999999,"emailVerified":true}
                """))
       .andExpect(status().isOk());
 
    // The request succeeded; the privileged fields must be unchanged.
    User alice = users.findById(aliceId).orElseThrow();
    assertThat(alice.role()).isEqualTo(Role.USER);
    assertThat(alice.creditBalance()).isZero();
}

The assertion is on the persisted state, not on the response. A DTO that silently ignores unknown fields returns 200 either way, so only checking the database proves the field was not applied.

A custom test annotation

WithTenantUser.java
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTenantUserSecurityContextFactory.class)
public @interface WithTenantUser {
    String username() default "alice";
    String tenantId() default "acme";
    String[] roles() default { "USER" };
}
 
public class WithTenantUserSecurityContextFactory
        implements WithSecurityContextFactory<WithTenantUser> {
 
    @Override
    public SecurityContext createSecurityContext(WithTenantUser annotation) {
        var jwt = Jwt.withTokenValue("test")
                .header("alg", "none")
                .subject(annotation.username())
                .claim("tenant_id", annotation.tenantId())
                .claim("roles", List.of(annotation.roles()))
                .build();
 
        var context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(new JwtAuthenticationToken(jwt,
                Arrays.stream(annotation.roles())
                      .map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList()));
        return context;
    }
}

Worth building once when your principal carries application-specific claims. @WithTenantUser(tenantId = "other") then makes cross-tenant tests a single line, and cross-tenant leakage is worth testing everywhere.

What to take away

Assert what is refused, not only what is permitted. Test both halves of CSRF so it cannot be silently disabled. Write positive and negative object-level tests for every endpoint taking a resource id, and verify the persisted state rather than the response. Cover method security separately for the callers no URL rule protects.

Frequently Asked Questions

What is the most valuable security test to write?
The negative one. Almost every team tests that an authorised user can reach an endpoint; far fewer test that an unauthorised one cannot. Broken object-level authorisation is the most common API vulnerability precisely because the happy-path test passes and nobody wrote the other one.
Why do my POST tests fail with 403?
CSRF. MockMvc does not add a token automatically, so any state-changing request is rejected. Add .with(csrf()) to the request. Do not disable CSRF to make tests pass — that removes the protection you are meant to be verifying.
@WithMockUser or @WithUserDetails?
@WithMockUser builds a synthetic principal and is fast, which suits controller tests. @WithUserDetails loads a real user through your UserDetailsService, so it exercises your actual authority mapping — better for integration tests where that mapping is what you want to verify.

Related tutorials