Test Spring Security Authentication with JUnit and MockMvc

Test Spring Security authentication with JUnit 6 and Spring Boot 4.1.1 using @WithMockUser, @WithUserDetails, httpBasic(), csrf() and 401 and 403 checks.

A request passes the Spring Security filter chain, which returns 401 when no user is authenticated, 403 when the user lacks the role or the CSRF token is missing, and otherwise reaches the controller and the @PreAuthorize check, with the test tools that set up each case

To test Spring Security authentication with JUnit, we run a request or a method call as a known user, for example with @WithMockUser, and assert the answer Spring Security gives, which is 200 for an allowed caller, 401 for a caller who is not logged in and 403 for a caller without the required role.

We write these tests for every secured endpoint and every @PreAuthorize method, because a wrong URL pattern or a missing role check opens data to the wrong users without any visible error. The spring-security-test module provides the test annotations and the MockMvc helpers.

The following example checks that a user with only the USER role cannot approve an expense.

@Test
@WithMockUser(roles = "USER")
void userCannotApproveExpenses() {
    assertThat(mvc.post().uri("/api/admin/expenses/2/approve").with(csrf())).hasStatus(403);   // passes
}

Notice that the test needs no password and no login request, because @WithMockUser puts an authenticated user into the security context before the test runs. We build a small secured app, test 401 and 403 in the web layer, compare the ways to choose the test user, test method security, and set the security context by hand. The examples use Spring Boot 4.1.1 with Spring Security 7.1.1 and JUnit 6.1.3 on Java 25.

1. The Secured Example App

Say a company runs an expense app. Employees see their own expenses, and only admins approve them. Spring Security checks two things on every request. Authentication answers who the caller is, and authorization answers what the caller may do. A test has to cover both, so each rule in the configuration gets at least one allowed and one rejected case.

A request passes the Spring Security filter chain, which returns 401 when no user is authenticated, 403 when the user lacks the role or the CSRF token is missing, and otherwise reaches the controller and the @PreAuthorize check, with the test tools that set up each case
Each test sets up the caller, and the filter chain decides between 401, 403 and the controller
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/public/**").permitAll()
                    .requestMatchers("/api/admin/**").hasRole("ADMIN")
                    .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
    return http.build();
}

@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
    return new InMemoryUserDetailsManager(
            User.withUsername("lokesh").password(encoder.encode("password1")).roles("USER").build(),
            User.withUsername("admin").password(encoder.encode("password2")).roles("USER", "ADMIN").build());
}

The configuration class carries @EnableMethodSecurity, so the service can protect a method on its own, independent of the URL rules. The in-memory users come from the UserDetailsService and HTTP Basic is the login method, as described in Spring Security HTTP Basic authentication.

@PreAuthorize("hasRole('ADMIN')")
public String approve(long id) {
    return "Expense " + id + " approved";
}

The tests need the Spring Boot 4 starters for Spring MVC tests and Spring Security tests. The security test starter brings spring-security-test together with spring-boot-starter-test, which contains JUnit Jupiter, AssertJ and Mockito.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security-test</artifactId>
  <scope>test</scope>
</dependency>

The project also sets junit-jupiter.version to 6.1.3, because Spring Boot 4.1.1 manages JUnit 6.0.3, as explained in JUnit version with Spring Boot.

2. Testing 401 and 403 Responses with MockMvcTester

A @WebMvcTest starts only the web layer, and when spring-security-test is on the classpath, Spring Boot applies the security filters to the auto-configured MockMvcTester. The slice does not scan @Configuration classes, so we import our SecurityConfig and mock the service with @MockitoBean. MockMvcTester from Spring Framework 7 offers AssertJ assertions such as hasStatus().

@WebMvcTest(ExpenseController.class)
@Import(SecurityConfig.class)
class ExpenseControllerTest {

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private ExpenseService expenseService;

    @Test
    void publicStatusNeedsNoLogin() {
        assertThat(mvc.get().uri("/api/public/status")).hasStatusOk().hasBodyTextEqualTo("UP");
    }

    @Test
    void anonymousCallerGets401() {
        assertThat(mvc.get().uri("/api/expenses")).hasStatus(401);
    }
}

A 401 response means that Spring Security does not know the caller, whereas a 403 response means that it knows the caller and refuses the request. The next tests show both sides of the role rule. @WithMockUser creates a user named user with the role USER unless we set username, roles or authorities, and the user does not have to exist anywhere.

@Test
@WithMockUser(username = "lokesh")
void mockUserSeesOwnExpenses() {
    given(expenseService.findByOwner("lokesh"))
            .willReturn(List.of(new Expense(1, "lokesh", "Train ticket", 45)));

    assertThat(mvc.get().uri("/api/expenses"))
            .hasStatusOk()
            .bodyJson().extractingPath("$[0].description").isEqualTo("Train ticket");
}

@Test
@WithMockUser(roles = "USER")
void userCannotApproveExpenses() {
    assertThat(mvc.post().uri("/api/admin/expenses/2/approve").with(csrf())).hasStatus(403);
}

The controller reads the user name from @AuthenticationPrincipal, so the first test also proves that the principal reaches the controller. The POST request carries csrf(), because CSRF protection is on by default and a POST without a token fails with 403 even for an admin.

@Test
@WithMockUser(roles = "ADMIN")
void postWithoutCsrfTokenIs403() {
    assertThat(mvc.post().uri("/api/admin/expenses/2/approve")).hasStatus(403);
}

3. Choosing the Test User

Spring Security offers several ways to set the caller of a test. The annotations from Spring Security’s test support work for MockMvc and for direct method calls, while the request post processors from SecurityMockMvcRequestPostProcessors work only on MockMvc requests.

ToolHow it sets the callerUser must existUse for
@WithMockUserMock user with name, roles or authoritiesNoMost authorization tests
@WithUserDetails(“admin”)Loads the user from the UserDetailsService beanYesCode that needs the real UserDetails type
@WithAnonymousUserAnonymous authenticationNoOverriding a class-level user for one test
user(“alex”).roles(“USER”)Request post processor, per requestNoSeveral users in one test
httpBasic(“lokesh”, “password1”)Real Authorization header, real password checkYesTesting the login itself
@WithSecurityContextCustom annotation with our own factoryDependsJWT or custom principal types

@WithUserDetails calls the UserDetailsService from our SecurityConfig, so the admin in the test has the same roles as in production. A post processor sets the user for one request only.

@Test
@WithUserDetails("admin")
void adminFromUserDetailsServiceCanApprove() {
    given(expenseService.approve(2)).willReturn("Expense 2 approved");

    assertThat(mvc.post().uri("/api/admin/expenses/2/approve").with(csrf()))
            .hasStatusOk()
            .hasBodyTextEqualTo("Expense 2 approved");
}

@Test
void userPostProcessorSetsTheCaller() {
    assertThat(mvc.post().uri("/api/admin/expenses/2/approve")
            .with(user("alex").roles("USER"))
            .with(csrf()))
            .hasStatus(403);
}

Only httpBasic() tests the authentication step itself. It sends the user name and password in a header, so the password encoder and the UserDetailsService run as in production, and a wrong password ends in 401.

@Test
void httpBasicWithRealPasswordIsAccepted() {
    assertThat(mvc.get().uri("/api/expenses").with(httpBasic("lokesh", "password1"))).hasStatusOk();
}

@Test
void httpBasicWithWrongPasswordIs401() {
    assertThat(mvc.get().uri("/api/expenses").with(httpBasic("lokesh", "wrong"))).hasStatus(401);
}
[INFO] Running com.howtodoinjava.junit.expenses.ExpenseControllerTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.080 s -- in com.howtodoinjava.junit.expenses.ExpenseControllerTest

4. Testing Method Security with @PreAuthorize

URL rules protect the web layer only. A scheduled job or a message listener that calls approve() bypasses them, so the role check on the service method needs its own tests. We start the application context with @SpringBootTest and call the bean, which Spring wraps in a proxy that evaluates @PreAuthorize.

@SpringBootTest
class ExpenseServiceSecurityTest {

    @Autowired
    private ExpenseService expenseService;

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanApprove() {
        assertEquals("Expense 2 approved", expenseService.approve(2));
    }

    @Test
    @WithMockUser(roles = "USER")
    void userIsDenied() {
        assertThrows(AccessDeniedException.class, () -> expenseService.approve(2));
    }

    @Test
    void noAuthenticationIsRejected() {
        assertThrows(AuthenticationCredentialsNotFoundException.class, () -> expenseService.approve(2));
    }
}

The two failure cases throw different exceptions. A caller without the role gets AccessDeniedException, which the web layer turns into 403. A call without any authentication gets AuthenticationCredentialsNotFoundException, which becomes 401. We check both with assertThrows() so that a refactoring cannot change one into the other unnoticed.

5. Setting the SecurityContext by Hand

Without the test annotations, we can put an Authentication into the SecurityContextHolder ourselves. That is useful in a test that does not run with the Spring TestContext framework, or when a test needs to switch the user in the middle. The context is stored in a thread-local, so the test must clear it afterward, or the user leaks into the next test on the same thread.

@AfterEach
void clearContext() {
    SecurityContextHolder.clearContext();
}

@Test
void adminSetByHandCanApprove() {
    Authentication admin = UsernamePasswordAuthenticationToken.authenticated(
            "admin", null, AuthorityUtils.createAuthorityList("ROLE_ADMIN"));
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    context.setAuthentication(admin);
    SecurityContextHolder.setContext(context);

    assertEquals("Expense 7 approved", expenseService.approve(7));
}

Here the authority needs the ROLE_ prefix, because hasRole(‘ADMIN’) checks for the authority ROLE_ADMIN. The roles attribute of @WithMockUser adds the prefix for us. The @AfterEach method runs even when the assertion fails, so the cleanup always happens.

6. Common Mistakes in Spring Security Tests

Most broken security tests fail for one of a few reasons. We can miss the first one for a long time, because the tests that expect 401 or 403 still pass, and only a test that expects free access fails.

  • The @WebMvcTest class does not import the security configuration. In that case Spring Boot applies its default security, which protects every URL, so even a public endpoint answers 401.
  • A POST, PUT, PATCH or DELETE request has no csrf(), so it ends in 403 although the user has the right role.
  • The test mixes roles = “ROLE_ADMIN” with the automatic prefix. @WithMockUser rejects a role that starts with ROLE_ with an IllegalArgumentException, so we write roles = “ADMIN” or authorities = “ROLE_ADMIN”.
  • @WithUserDetails names a user that the UserDetailsService does not know, so the test fails before it starts with IllegalStateException, caused by UsernameNotFoundException.

The first mistake shows up as a failed status assertion. The following output comes from the controller test with the @Import(SecurityConfig.class) line removed.

[ERROR] Failures:
[ERROR]   ExpenseControllerTest.publicStatusNeedsNoLogin:31 [HTTP status code]
expected: 200
 but was: 401

7. Spring Security Testing FAQs

Most questions about these tests come from the choice of test user and from a 403 response where the test expected 200.

7.1. What Is the Difference Between @WithMockUser and @WithUserDetails?

@WithMockUser creates a user from the annotation attributes, so the user does not need to exist. @WithUserDetails loads the user from the UserDetailsService bean, so the test sees the real roles and the real UserDetails class, but the user must exist in the test context.

7.2. Why Does My Test Return 403 Instead of 200?

In most cases the request is missing a CSRF token or the user lacks the role. We add .with(csrf()) to every state-changing request and compare the roles of the test user with the hasRole() rules in the security configuration.

7.3. Do We Need @ExtendWith(SpringExtension.class) for These Tests?

No. @WebMvcTest and @SpringBootTest already include the SpringExtension. Only a plain Spring test with @ContextConfiguration needs @ExtendWith(SpringExtension.class) or @SpringJUnitConfig.

7.4. Can We Use @WithMockUser on the Test Class?

Yes. A class-level @WithMockUser applies to every test method, and a method can override it with its own annotation or with @WithAnonymousUser.

8. Conclusion

Spring Security tests assert what the security layer decides. In the web layer, a @WebMvcTest with the imported security configuration and MockMvcTester checks 401 for anonymous callers, 403 for missing roles or CSRF tokens, and 200 for allowed users. @WithMockUser covers most cases, @WithUserDetails and httpBasic() bring in the real users and passwords.

Method security needs its own tests with @SpringBootTest, where AccessDeniedException and AuthenticationCredentialsNotFoundException tell the two failure cases apart. The new features of the framework version used here are in Spring Security 7, and the JUnit tutorial covers the JUnit side.

9. References

Happy Learning !!

Source Code on Github

Leave a Comment

      • Yes, its not related to this article, basically I am looking for the way to set spring user context or principal into the SecurityContext without the use of AuthenticationManager set in XML file. Could you please help ?

        Authentication authToken = new UsernamePasswordAuthenticationToken(username, password,authorities);
        SecurityContextHolder.getContext().setAuthentication(authToken);
        Not sure how we can checked if its correctly set

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.