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.
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.@WithUserDetailsexercises your real authority mapping;@WithMockUserdoes 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
@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
// 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
@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
@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
@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
@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?
Why do my POST tests fail with 403?
@WithMockUser or @WithUserDetails?
Related tutorials
- Penetration Testing for Java AppsA structured approach to testing your own application: reconnaissance, authentication and authorisation testing, injection, business logic flaws, and the tools that help.
- DevSecOps — Securing the PipelineSecurity gates that catch real problems without blocking delivery: pre-commit secret scanning, SAST with FindSecBugs, dependency and container scanning, DAST, and tuning out the noise.
- Threat ModellingFinding design flaws before they ship: drawing data flow diagrams, applying STRIDE per element, prioritising with DREAD, and running a session that produces actionable work.
- Secrets Management & Key SecurityGetting secrets out of configuration: taking an inventory, Vault KV and dynamic database credentials, Kubernetes auth, the External Secrets Operator, and rotation that works.