Spring Security Testing: @WithMockUser, JWT, and Method Security
Spring Security is the most undertested layer in most Spring Boot apps. This post shows how to use @WithMockUser, @WithSecurityContext, mockJwt(), and method security tests to actually verify your authorization rules.
Security is the layer where bugs have the worst consequences — an unprotected endpoint or a misconfigured role check is not a UI glitch, it is a data breach. Yet most Spring Boot projects test the happy path through security (authenticated user, correct role, gets a 200) and skip the adversarial cases entirely.
Spring Security Test gives you a complete toolkit. This post covers all of it: user mocking, custom security contexts, CSRF, OAuth2/JWT, and method-level authorization.
Setup
Add spring-security-test to your test scope:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>Spring Boot manages the version via its BOM. No explicit version needed.
@WithMockUser: The Starting Point
@WithMockUser creates a UsernamePasswordAuthenticationToken and places it in the SecurityContext for the test's duration. It is the fastest way to simulate an authenticated user.
@SpringBootTest
@AutoConfigureMockMvc
class ArticleControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void unauthenticated_returns401() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "alice", roles = {"USER"})
void authenticatedUser_canReadArticles() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "bob", roles = {"ADMIN"})
void admin_canDeleteArticle() throws Exception {
mockMvc.perform(delete("/api/articles/42").with(csrf()))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(username = "charlie", roles = {"USER"})
void nonAdmin_cannotDeleteArticle() throws Exception {
mockMvc.perform(delete("/api/articles/42").with(csrf()))
.andExpect(status().isForbidden());
}
}The roles attribute auto-prepends ROLE_, so roles = {"ADMIN"} becomes the authority ROLE_ADMIN. Use authorities for raw authority strings:
@WithMockUser(username = "dave", authorities = {"article:read", "article:write"})Class-Level vs Method-Level
Apply @WithMockUser at the class level to set a default for all tests, then override individual methods with @WithAnonymousUser or a different @WithMockUser:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser(roles = "USER")
class ArticleControllerTest {
@Test
void authenticatedUserSeesArticles() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isOk());
}
@Test
@WithAnonymousUser
void anonymousUserIsRejected() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminSeesAdminPanel() throws Exception {
mockMvc.perform(get("/admin/dashboard"))
.andExpect(status().isOk());
}
}SecurityMockMvcRequestPostProcessors
Post-processors apply security context per-request rather than per-test-method. Useful when one test exercises multiple security contexts, or when you want request-scoped setup.
CSRF
CSRF protection is enabled by default. POST, PUT, DELETE without a valid CSRF token get a 403. Use csrf() to include one:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
@Test
@WithMockUser(roles = "USER")
void submitComment_requiresCsrfToken() throws Exception {
mockMvc.perform(post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"text": "Great post!", "articleId": 42}"""))
.andExpect(status().isForbidden()); // no csrf token
mockMvc.perform(post("/api/comments")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""{"text": "Great post!", "articleId": 42}"""))
.andExpect(status().isCreated()); // with csrf token
}HTTP Basic
@Test
void httpBasic_authenticatesCorrectCredentials() throws Exception {
mockMvc.perform(get("/api/articles")
.with(httpBasic("alice", "correct-password")))
.andExpect(status().isOk());
}
@Test
void httpBasic_rejectsWrongPassword() throws Exception {
mockMvc.perform(get("/api/articles")
.with(httpBasic("alice", "wrong-password")))
.andExpect(status().isUnauthorized());
}user() Post-Processor
Equivalent to @WithMockUser but applied per-request:
@Test
void editorCanPublishArticle() throws Exception {
mockMvc.perform(post("/api/articles/99/publish")
.with(user("editor").roles("EDITOR"))
.with(csrf()))
.andExpect(status().isOk());
}@WithSecurityContext: Custom Authentication
When your application uses a custom UserDetails implementation (like a multi-tenant TenantUserDetails), @WithMockUser is not enough. Build a custom annotation:
// 1. 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. 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. Usage
@Test
@WithTenantUser(username = "alice", tenantId = 42L, roles = {"ADMIN"})
void tenantAdmin_canManageOwnTenantData() throws Exception {
mockMvc.perform(get("/api/tenants/42/users"))
.andExpect(status().isOk());
}
@Test
@WithTenantUser(username = "alice", tenantId = 42L, roles = {"ADMIN"})
void tenantAdmin_cannotAccessOtherTenant() throws Exception {
mockMvc.perform(get("/api/tenants/99/users"))
.andExpect(status().isForbidden());
}Testing Method-Level Security
@PreAuthorize and @PostAuthorize are enforced by Spring AOP proxies. They work in @SpringBootTest contexts, but not through @WebMvcTest service mocks (the proxy wraps the real bean, not the mock).
Test them by injecting the service directly:
@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);
}
}@SpringBootTest
class ArticleServiceSecurityTest {
@Autowired
private ArticleService articleService;
@Autowired
private ArticleRepository articleRepository;
@Test
@WithMockUser(username = "alice", roles = {"USER"})
void authorCanUpdateOwnArticle() {
Article article = articleRepository.save(
new Article("My Article", "alice", ArticleStatus.DRAFT));
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 = articleRepository.save(
new Article("Alice's Article", "alice", ArticleStatus.DRAFT));
assertThatThrownBy(() -> articleService.updateArticle(article))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithMockUser(roles = {"ADMIN"})
void adminCanDeleteAnyArticle() {
Article article = articleRepository.save(
new Article("Test", "alice", ArticleStatus.PUBLISHED));
assertThatCode(() -> articleService.deleteArticle(article.getId()))
.doesNotThrowAnyException();
}
@Test
@WithMockUser(roles = {"USER"})
void regularUserCannotDeleteArticle() {
Article article = articleRepository.save(
new Article("Test", "alice", ArticleStatus.PUBLISHED));
assertThatThrownBy(() -> articleService.deleteArticle(article.getId()))
.isInstanceOf(AccessDeniedException.class);
}
}Testing OAuth2 and JWT
Modern APIs use OAuth2/OIDC. Spring Security Test provides mockOidcLogin() and jwt() post-processors to simulate these without a real authorization server.
OIDC Login
@SpringBootTest
@AutoConfigureMockMvc
class OidcUserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void oidcUser_canAccessProfile() 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 oidcUserWithAdminAuthority_canAccessAdminPanel() throws Exception {
mockMvc.perform(get("/admin")
.with(oidcLogin()
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
.idToken(token -> token.subject("admin-456"))))
.andExpect(status().isOk());
}
}JWT Resource Server
For APIs that validate JWT bearer tokens:
@SpringBootTest
@AutoConfigureMockMvc
class JwtResourceServerTest {
@Autowired
private MockMvc mockMvc;
@Test
void validJwt_grantsAccess() throws Exception {
mockMvc.perform(get("/api/protected")
.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 jwtWithoutRequiredScope_isForbidden() throws Exception {
mockMvc.perform(get("/api/protected")
.with(jwt()
.jwt(token -> token
.subject("user-789")
.claim("scope", "read:profile"))))
.andExpect(status().isForbidden());
}
@Test
void requestWithoutToken_isUnauthorized() throws Exception {
mockMvc.perform(get("/api/protected"))
.andExpect(status().isUnauthorized());
}
}Systematic Role Coverage with @ParameterizedTest
Rather than writing separate test methods for each role-endpoint combination, use parameterized tests:
@SpringBootTest
@AutoConfigureMockMvc
class RbacSecurityTest {
@Autowired
private MockMvc mockMvc;
@ParameterizedTest
@MethodSource("adminOnlyEndpoints")
void adminEndpoints_rejectNonAdmins(String method, String url) throws Exception {
mockMvc.perform(buildRequest(method, url)
.with(user("user").roles("USER")))
.andExpect(status().isForbidden());
}
@ParameterizedTest
@MethodSource("adminOnlyEndpoints")
void adminEndpoints_allowAdmins(String method, String url) throws Exception {
mockMvc.perform(buildRequest(method, url)
.with(user("admin").roles("ADMIN"))
.with(csrf()))
.andExpect(status().not(status().isForbidden().getMatcher()));
}
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);
};
}
}This pattern makes it easy to add new admin endpoints to the list and immediately verify they are covered by both the positive and negative tests.
Key Pitfalls
Forgetting .with(csrf()) is the most common cause of unexpected 403s in POST/PUT/DELETE tests. If a state-changing test fails with 403 and you have no idea why, check CSRF first.
@WebMvcTest does not test method security. @PreAuthorize fires on real service beans, not on @MockBean. If you want to verify method-level access control, use @SpringBootTest and inject the actual service.
Always write the negative test. For every endpoint you test with an authorized user, write a matching test with an unauthorized user or wrong role. The positive test only confirms the happy path works; the negative test confirms the protection is actually enforced.