Spring Security Testing: @WithMockUser, SecurityMockMvcRequestPostProcessors, and Method Security
Security is arguably the most critical layer of any web application, yet it's also one of the most commonly undertested areas. Developers often write happy-path controller tests that skip authentication and authorization entirely, only to discover gaps when a penetration tester — or worse, an attacker — probes the application in production. Spring Security's test support library gives you a rich set of tools to verify that your security configuration actually does what you think it does.
This guide walks through the full spectrum of Spring Security testing: from basic user mocking with @WithMockUser to testing JWT-based OAuth2 flows and method-level security annotations.
Setting Up the Dependency
Before anything else, add spring-security-test to your project. With Maven:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>With Gradle:
testImplementation 'org.springframework.security:spring-security-test'Spring Boot's dependency management handles the version for you, so no explicit version is required. This dependency provides SecurityMockMvcRequestPostProcessors, the @WithMockUser family of annotations, and the OAuth2 test support utilities.
Basic Test Setup with MockMvc
Most Spring Security tests revolve around MockMvc. Annotate your test class with @SpringBootTest and @AutoConfigureMockMvc to get a fully wired application context, or use @WebMvcTest for a lighter slice that only loads the web layer.
@SpringBootTest
@AutoConfigureMockMvc
class ArticleControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void unauthenticatedUserCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/admin/articles"))
.andExpect(status().isUnauthorized());
}
}This test confirms that unauthenticated requests are rejected. But you also need to verify that authenticated users with the right roles can access protected resources — that's where the test support utilities shine.
@WithMockUser: Simulating Authenticated Users
@WithMockUser is the simplest way to inject an authenticated principal into a test. It creates a UsernamePasswordAuthenticationToken and places it in the SecurityContext for the duration of the test method.
@Test
@WithMockUser(username = "alice", roles = {"USER"})
void authenticatedUserCanReadArticles() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").exists());
}
@Test
@WithMockUser(username = "bob", roles = {"ADMIN"})
void adminCanDeleteArticle() throws Exception {
mockMvc.perform(delete("/api/articles/42"))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(username = "charlie", roles = {"USER"})
void nonAdminCannotDeleteArticle() throws Exception {
mockMvc.perform(delete("/api/articles/42"))
.andExpect(status().isForbidden());
}The roles attribute automatically prepends ROLE_ to each value, so roles = {"ADMIN"} becomes the authority ROLE_ADMIN. If you need raw authority strings without the prefix, use authorities instead:
@WithMockUser(username = "dave", authorities = {"article:read", "article:write"})@WithAnonymousUser
To explicitly test anonymous (unauthenticated) access inside a class that has a class-level @WithMockUser, use @WithAnonymousUser:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser(roles = "USER")
class ArticleControllerTest {
@Test
void authenticatedUserSeesProfile() throws Exception {
mockMvc.perform(get("/profile"))
.andExpect(status().isOk());
}
@Test
@WithAnonymousUser
void anonymousUserIsRedirectedToLogin() throws Exception {
mockMvc.perform(get("/profile"))
.andExpect(status().isUnauthorized());
}
}SecurityMockMvcRequestPostProcessors
Post-processors apply security context directly to individual MockMvc requests rather than the entire test method. This is useful when you need to test multiple security contexts in a single test, or when you want a more explicit, request-scoped approach.
CSRF Protection
Spring Security enables CSRF protection by default for state-changing requests. Tests that POST, PUT, or DELETE without a valid CSRF token will receive a 403 Forbidden. Use csrf() to include a valid token:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
@Test
@WithMockUser(roles = "USER")
void userCanSubmitComment() throws Exception {
String commentJson = """
{"text": "Great article!", "articleId": 42}
""";
mockMvc.perform(post("/api/comments")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(commentJson))
.andExpect(status().isCreated());
}HTTP Basic Authentication
For APIs using HTTP Basic auth, use httpBasic():
@Test
void httpBasicAuthenticatesSuccessfully() throws Exception {
mockMvc.perform(get("/api/articles")
.with(httpBasic("alice", "secret")))
.andExpect(status().isOk());
}
@Test
void wrongPasswordIsRejected() throws Exception {
mockMvc.perform(get("/api/articles")
.with(httpBasic("alice", "wrong-password")))
.andExpect(status().isUnauthorized());
}User Post-Processor
The user() post-processor provides a fluent API equivalent to @WithMockUser:
@Test
void editorCanPublishArticle() throws Exception {
mockMvc.perform(post("/api/articles/99/publish")
.with(user("editor").roles("EDITOR"))
.with(csrf()))
.andExpect(status().isOk());
}@WithSecurityContext: Custom Security Contexts
Sometimes @WithMockUser is not enough — you need a fully custom Authentication object with a domain-specific UserDetails implementation. Create a custom annotation backed by a WithSecurityContextFactory:
// 1. Define the annotation
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTenantUserSecurityContextFactory.class)
public @interface WithTenantUser {
String username() default "tenant-user";
long tenantId() default 1L;
String[] roles() default {"USER"};
}
// 2. Implement the factory
public class WithTenantUserSecurityContextFactory
implements WithSecurityContextFactory<WithTenantUser> {
@Override
public SecurityContext createSecurityContext(WithTenantUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
TenantUserDetails userDetails = new TenantUserDetails(
annotation.username(),
annotation.tenantId(),
Arrays.stream(annotation.roles())
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.collect(Collectors.toList())
);
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
context.setAuthentication(auth);
return context;
}
}
// 3. Use it in tests
@Test
@WithTenantUser(username = "alice", tenantId = 42L, roles = {"ADMIN"})
void tenantAdminCanManageOwnTenantData() throws Exception {
mockMvc.perform(get("/api/tenants/42/users"))
.andExpect(status().isOk());
}
@Test
@WithTenantUser(username = "alice", tenantId = 42L, roles = {"ADMIN"})
void tenantAdminCannotAccessOtherTenantData() throws Exception {
mockMvc.perform(get("/api/tenants/99/users"))
.andExpect(status().isForbidden());
}Testing Method-Level Security
Method-level security annotations like @PreAuthorize and @PostAuthorize are evaluated by Spring's AOP proxy, which means they work in @SpringBootTest contexts but not in @WebMvcTest slices (which don't load service beans by default).
Consider this service:
@Service
public class ArticleService {
@PreAuthorize("hasRole('EDITOR') or #article.authorUsername == authentication.name")
public Article updateArticle(Article article) {
return articleRepository.save(article);
}
@PreAuthorize("hasRole('ADMIN')")
public void deleteArticle(Long id) {
articleRepository.deleteById(id);
}
@PostAuthorize("returnObject.authorUsername == authentication.name or hasRole('ADMIN')")
public Article getArticle(Long id) {
return articleRepository.findById(id).orElseThrow();
}
}Test these methods by injecting the service into a @SpringBootTest:
@SpringBootTest
class ArticleServiceSecurityTest {
@Autowired
private ArticleService articleService;
@Autowired
private ArticleRepository articleRepository;
@Test
@WithMockUser(username = "alice", roles = {"USER"})
void authorCanUpdateOwnArticle() {
Article article = new Article();
article.setTitle("My Article");
article.setAuthorUsername("alice");
articleRepository.save(article);
article.setTitle("Updated Title");
Article updated = articleService.updateArticle(article);
assertThat(updated.getTitle()).isEqualTo("Updated Title");
}
@Test
@WithMockUser(username = "bob", roles = {"USER"})
void nonAuthorCannotUpdateOtherUsersArticle() {
Article article = new Article();
article.setTitle("Alice's Article");
article.setAuthorUsername("alice");
articleRepository.save(article);
assertThatThrownBy(() -> articleService.updateArticle(article))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(roles = {"ADMIN"})
void adminCanDeleteAnyArticle() {
Article article = articleRepository.save(new Article("Test", "alice"));
assertThatCode(() -> articleService.deleteArticle(article.getId()))
.doesNotThrowAnyException();
}
@Test
@WithMockUser(username = "charlie", roles = {"USER"})
void userCannotDeleteArticle() {
Article article = articleRepository.save(new Article("Test", "alice"));
assertThatThrownBy(() -> articleService.deleteArticle(article.getId()))
.isInstanceOf(AccessDeniedException.class);
}
}Testing OAuth2 and JWT Authentication
Modern applications frequently use OAuth2/OIDC for authentication. Spring Security Test provides mockOidcLogin() and mockJwt() post-processors for testing these flows without a real authorization server.
Testing with mockOidcLogin()
@SpringBootTest
@AutoConfigureMockMvc
class OidcUserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void oidcUserCanAccessProfile() throws Exception {
mockMvc.perform(get("/profile")
.with(oidcLogin()
.idToken(token -> token
.subject("user-123")
.claim("email", "alice@example.com")
.claim("name", "Alice Smith"))
.userInfoToken(token -> token
.claim("given_name", "Alice"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void oidcUserWithAdminScopeCanAccessAdminPanel() throws Exception {
mockMvc.perform(get("/admin")
.with(oidcLogin()
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
.idToken(token -> token.subject("admin-456"))))
.andExpect(status().isOk());
}
}Testing with mockJwt()
For resource servers that validate JWT bearer tokens:
@SpringBootTest
@AutoConfigureMockMvc
class JwtResourceServerTest {
@Autowired
private MockMvc mockMvc;
@Test
void validJwtGrantsAccess() throws Exception {
mockMvc.perform(get("/api/protected-resource")
.with(jwt()
.jwt(token -> token
.subject("user-789")
.claim("scope", "read:articles")
.claim("tenant_id", "42"))
.authorities(new SimpleGrantedAuthority("SCOPE_read:articles"))))
.andExpect(status().isOk());
}
@Test
void jwtWithoutRequiredScopeIsForbidden() throws Exception {
mockMvc.perform(get("/api/protected-resource")
.with(jwt()
.jwt(token -> token
.subject("user-789")
.claim("scope", "read:profile"))))
.andExpect(status().isForbidden());
}
@Test
void requestWithoutTokenIsUnauthorized() throws Exception {
mockMvc.perform(get("/api/protected-resource"))
.andExpect(status().isUnauthorized());
}
}Testing Role-Based Access Control Comprehensively
A good security test suite systematically verifies every endpoint against every role. Consider a parameterized approach to keep tests DRY:
@SpringBootTest
@AutoConfigureMockMvc
class RbacSecurityTest {
@Autowired
private MockMvc mockMvc;
@ParameterizedTest
@MethodSource("adminOnlyEndpoints")
void adminOnlyEndpointsRejectNonAdmins(String method, String url) throws Exception {
MockHttpServletRequestBuilder request = buildRequest(method, url);
mockMvc.perform(request.with(user("user").roles("USER")))
.andExpect(status().isForbidden());
}
@ParameterizedTest
@MethodSource("adminOnlyEndpoints")
void adminOnlyEndpointsAllowAdmins(String method, String url) throws Exception {
MockHttpServletRequestBuilder request = buildRequest(method, url);
mockMvc.perform(request
.with(user("admin").roles("ADMIN"))
.with(csrf()))
.andExpect(status().isNot(HttpStatus.FORBIDDEN.value()));
}
static Stream<Arguments> adminOnlyEndpoints() {
return Stream.of(
Arguments.of("GET", "/admin/users"),
Arguments.of("DELETE", "/admin/users/1"),
Arguments.of("POST", "/admin/config"),
Arguments.of("GET", "/actuator/env")
);
}
private MockHttpServletRequestBuilder buildRequest(String method, String url) {
return switch (method) {
case "GET" -> get(url);
case "POST" -> post(url);
case "DELETE" -> delete(url);
default -> throw new IllegalArgumentException("Unknown method: " + method);
};
}
}Key Patterns and Pitfalls
Always test the negative case. For every access control rule, write at least one test that verifies unauthorized access is rejected, not just that authorized access is granted.
Use @WithMockUser at the class level carefully. If you put it on the class, all methods inherit it. Use @WithAnonymousUser on individual methods to override for unauthenticated tests.
CSRF and state-changing requests. If you're getting unexpected 403s in tests that should succeed, you've likely forgotten .with(csrf()). This is the most common mistake.
@WebMvcTest doesn't load the full security config. It loads WebSecurityConfigurerAdapter subclasses, but if your security config depends on beans not in the web layer, you'll need @SpringBootTest or manual @Import.
Method security requires a real proxy. @PreAuthorize and @PostAuthorize require @SpringBootTest — they won't fire if you call service methods directly without going through the Spring proxy.
Automating Security Regression Testing
Writing these tests by hand is essential, but maintaining them as your application evolves requires continuous monitoring. Security regressions — an endpoint that was protected becoming accidentally public, or a role that gained access it shouldn't have — are subtle and dangerous.
HelpMeTest lets you run your Spring Security test suites continuously as part of your deployment pipeline, alerting you the moment a protected endpoint becomes accessible without authentication or when an access control rule changes unexpectedly. Rather than discovering security regressions in production, you catch them at the pull request stage.
Security is never a one-time concern. Pair thorough spring-security-test coverage with automated monitoring, and your application's security posture becomes a living, continuously verified guarantee rather than a snapshot from the last time someone ran the tests.