Spring Security Testing: @WithMockUser, JWT, and Method Security

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.

Read more

Start now free