Testing Spring Security: @WithMockUser, CSRF and JWT

Introduction

This tutorial explains how to test an application secured with Spring Security, using the spring-security-test module. We will test the URL rules of a web layer with @WithMockUser and the user() post processor, see the difference between 401 and 403, show why a POST without a CSRF token fails and how csrf() fixes it, and check a real password. Then we will test JWT-protected endpoints with the jwt() post processor and with a real signed token, and finally test @PreAuthorize method security with @WithMockUser and @WithUserDetails.

How the test tools work

Spring Security decides everything from the SecurityContext: the authentication filters put the logged-in user into it, and the URL rules and method security read it. The test tools fall into two groups. Most of them put a ready-made Authentication straight into the context, skipping the login. The others build a real request detail - a CSRF token, a Basic header - that goes through the real filters - Where each Spring Security test tool steps into a request - csrf() and httpBasic() add request details checked by the real filters, while @WithMockUser, @WithUserDetails, user() and jwt() put a ready Authentication into the SecurityContext Skipping the login is what makes these tests short: a test about "only admins may delete" should not have to know passwords or obtain tokens. The login itself is tested separately, in a few tests that send real credentials. Spring Boot's spring-boot-starter-security-test brings the module; Spring Boot adds its support to MockMvc automatically. The concepts of the filter chain, CSRF and JWTs are covered in the Spring Security tutorialsClick to read tutorial: Spring Security Architecture: Filter Chain Explained of this series.

The application

A small reports application has two kinds of clients. Browsers and scripts with a username and password use /reports, protected by HTTP Basic, with CSRF protection on, and deleting a report is for admins only. Other services use /api/** with a JWT bearer token, stateless and without CSRF, and creating a report there needs the scope reports.write. A service, protected with @PreAuthorize, lets users read their own reports, and admins everything -
src/main/java/.../ReportController.java
//ReportController.java
package com.javabrahman.testing.security;

import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ReportController {

  @GetMapping("/public/hello")
  public String hello() {
    return "hello";
  }

  @GetMapping("/reports")
  public String reports(Authentication user) {
    return "reports of " + user.getName();
  }

  @PostMapping("/reports")
  public ResponseEntity<String> create(Authentication user) {
    return ResponseEntity.status(HttpStatus.CREATED).body("created by " + user.getName());
  }

  @DeleteMapping("/reports/{id}")
  public ResponseEntity<Void> delete(@PathVariable long id) {
    return ResponseEntity.noContent().build();
  }

  @GetMapping("/api/me")
  public Map<String, Object> me(Authentication token) {
    return Map.of("name", token.getName(),
        "authorities", token.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList());
  }

  @PostMapping("/api/reports")
  public ResponseEntity<String> createViaApi(Authentication token) {
    return ResponseEntity.status(HttpStatus.CREATED).body("created via API by " + token.getName());
  }
}
src/main/java/.../ReportService.java - protected with method security
//ReportService.java
package com.javabrahman.testing.security;

import java.util.List;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class ReportService {
  private final Map<String, List<String>> byOwner = Map.of("ann", List.of("ann-q1", "ann-q2"), "raj", List.of("raj-q1"));

  @PreAuthorize("#owner == authentication.name or hasRole('ADMIN')")
  public List<String> reportsOf(String owner) {
    return byOwner.getOrDefault(owner, List.of());
  }

  @PreAuthorize("hasRole('ADMIN')")
  public String delete(long id) {
    return "deleted report " + id;
  }
}
src/main/java/.../SecurityConfig.java - two filter chains
//SecurityConfig.java
package com.javabrahman.testing.security;

import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
  // a shared HMAC secret keeps the example self-contained; real resource servers use the issuer's public keys
  public static final SecretKey JWT_KEY =
      new SecretKeySpec("demo-secret-for-hs256-at-least-32-bytes".getBytes(StandardCharsets.UTF_8), "HmacSHA256");

  @Bean
  @Order(1)
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/reports").hasAuthority("SCOPE_reports.write")
            .anyRequest().authenticated())
        .oauth2ResourceServer(server -> server.jwt(Customizer.withDefaults()))
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable());              // no cookies, no session: CSRF does not apply
    return http.build();
  }

  @Bean
  @Order(2)
  SecurityFilterChain web(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .requestMatchers(HttpMethod.DELETE, "/reports/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .httpBasic(Customizer.withDefaults());       // CSRF protection stays on (the default)
    return http.build();
  }

  @Bean
  UserDetailsService users(PasswordEncoder encoder) {
    return new InMemoryUserDetailsManager(
        User.withUsername("ann").password(encoder.encode("ann-pass")).roles("USER").build(),
        User.withUsername("raj").password(encoder.encode("raj-pass")).roles("USER", "ADMIN").build());
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Bean
  JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withSecretKey(JWT_KEY).build();
  }
}
src/main/java/.../ReportsApp.java
//ReportsApp.java
package com.javabrahman.testing.security;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ReportsApp {

  public static void main(String[] args) {
    SpringApplication.run(ReportsApp.class, args);
  }
}
The two users are "ann", with role USER, and "raj", with roles USER and ADMIN. The JWT decoder checks the signature of tokens with a shared HMAC secret, which keeps the example self-contained; a real resource server uses the public keys of its authorization server, as shown in the resource server tutorialClick to read tutorial: Spring Security OAuth2 Resource Server with JWT.

Testing users, roles and CSRF

The test is a @WebMvcTest of the controller, which @Imports the application's SecurityConfig, so that the real rules apply. Without the import, the slice would apply Spring Boot's default security instead, and the test would check rules that the application does not have. @WithMockUser on a test method runs it as an authenticated user, by default "user" with the role USER; the post processors user(), csrf() and httpBasic() are added to a single request with .with(...) -
src/test/java/.../ReportWebTest.java - users, roles and CSRF
//ReportWebTest.java
package com.javabrahman.testing.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(ReportController.class)
@Import(SecurityConfig.class)                  // the application's real security rules
class ReportWebTest {

  @Autowired
  MockMvcTester mvc;

  @Test
  void anonymousGets401() {
    assertThat(mvc.get().uri("/reports")).hasStatus(HttpStatus.UNAUTHORIZED);
  }

  @Test
  void publicPageNeedsNoLogin() {
    assertThat(mvc.get().uri("/public/hello")).hasStatusOk().bodyText().isEqualTo("hello");
  }

  @Test
  @WithMockUser("ann")                         // an authenticated user "ann" with role USER, no password check
  void mockUserSeesReports() {
    assertThat(mvc.get().uri("/reports")).hasStatusOk().bodyText().isEqualTo("reports of ann");
  }

  @Test
  @WithMockUser("ann")
  void userCannotDelete() {
    assertThat(mvc.delete().uri("/reports/7").with(csrf())).hasStatus(HttpStatus.FORBIDDEN);
  }

  @Test
  void adminCanDelete() {
    assertThat(mvc.delete().uri("/reports/7").with(user("raj").roles("ADMIN")).with(csrf()))
        .hasStatus(HttpStatus.NO_CONTENT);
  }

  @Test
  @WithMockUser("ann")
  void postWithoutCsrfTokenIsRejected() {
    assertThat(mvc.post().uri("/reports")).hasStatus(HttpStatus.FORBIDDEN);
  }

  @Test
  @WithMockUser("ann")
  void postWithCsrfTokenIsAccepted() {
    assertThat(mvc.post().uri("/reports").with(csrf())).hasStatus(HttpStatus.CREATED)
        .bodyText().isEqualTo("created by ann");
  }

  @Test
  void realPasswordCheck() {
    assertThat(mvc.get().uri("/reports").with(httpBasic("ann", "ann-pass"))).hasStatusOk();
    assertThat(mvc.get().uri("/reports").with(httpBasic("ann", "wrong"))).hasStatus(HttpStatus.UNAUTHORIZED);
  }
}
 OUTPUT - users, roles and CSRF
╷
└─ JUnit Jupiter ✔
   └─ ReportWebTest ✔
      ├─ realPasswordCheck() ✔
      ├─ mockUserSeesReports() ✔
      ├─ postWithoutCsrfTokenIsRejected() ✔
      ├─ anonymousGets401() ✔
      ├─ publicPageNeedsNoLogin() ✔
      ├─ userCannotDelete() ✔
      ├─ adminCanDelete() ✔
      └─ postWithCsrfTokenIsAccepted() ✔

[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         8 tests found           ]
[         0 tests skipped         ]
[         8 tests started         ]
[         0 tests aborted         ]
[         8 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • 401 and 403: an anonymous request got 401 Unauthorized - "who are you?" - while ann, logged in but without the role ADMIN, got 403 Forbidden on the delete - "you may not". Testing both tells apart a missing login from a missing permission.
  • A ready user: @WithMockUser("ann") made the controller see "ann" as the user, without any password. user("raj").roles("ADMIN") did the same for one request, and the delete returned 204.
  • CSRF: ann's POST without a token was rejected with 403, although she was logged in, because the web chain keeps CSRF protection on. The same POST with csrf(), which adds a valid token, returned 201. A test of a POST, PUT or DELETE in a chain with CSRF protection needs csrf(); a 403 in such a test is often just a missing token.
  • A real password check: httpBasic("ann", "ann-pass") sent a real Basic header, which went through the authentication filter, the UserDetailsService and the password encoder. The wrong password got 401.

Testing JWT-protected endpoints

For a resource server, the jwt() post processor puts a JwtAuthenticationToken into the context, built from a JWT that is never encoded nor checked. Its claims and authorities can be set as needed. A test may also sign a real token and send it in the Authorization header, which then goes through the real JwtDecoder -
src/test/java/.../ApiJwtTest.java - JWT-protected endpoints
//ApiJwtTest.java
package com.javabrahman.testing.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;

@WebMvcTest(ReportController.class)
@Import(SecurityConfig.class)
class ApiJwtTest {

  @Autowired
  MockMvcTester mvc;

  @Test
  void noTokenGets401() {
    MvcTestResult result = mvc.get().uri("/api/me").exchange();
    System.out.println("   WWW-Authenticate: " + result.getResponse().getHeader("WWW-Authenticate"));
    assertThat(result).hasStatus(HttpStatus.UNAUTHORIZED);
  }

  @Test
  void jwtPostProcessor() throws Exception {
    MvcTestResult result = mvc.get().uri("/api/me")
        .with(jwt().jwt(token -> token.subject("ann").claim("scope", "reports.read"))).exchange();
    System.out.println("   /api/me: " + result.getResponse().getContentAsString());
    assertThat(result).hasStatusOk();
  }

  @Test
  void missingScopeIsForbidden() {
    assertThat(mvc.post().uri("/api/reports").with(jwt())).hasStatus(HttpStatus.FORBIDDEN);
  }

  @Test
  void writeScopeIsAllowed() {
    assertThat(mvc.post().uri("/api/reports")
        .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_reports.write"))))
        .hasStatus(HttpStatus.CREATED).bodyText().isEqualTo("created via API by user");
  }

  @Test
  void realSignedTokenGoesThroughTheDecoder() throws Exception {
    String token = token("raj");
    MvcTestResult result = mvc.get().uri("/api/me").header("Authorization", "Bearer " + token).exchange();
    System.out.println("   with a real token: " + result.getResponse().getContentAsString());
    assertThat(result).hasStatusOk();

    MvcTestResult tampered = mvc.get().uri("/api/me").header("Authorization", "Bearer " + token("raj") + "x").exchange();
    System.out.println("   with a tampered token: " + tampered.getResponse().getHeader("WWW-Authenticate"));
    assertThat(tampered).hasStatus(HttpStatus.UNAUTHORIZED);
  }

  private static String token(String subject) {
    JwtClaimsSet claims = JwtClaimsSet.builder().subject(subject).claim("scope", "reports.read reports.write")
        .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)).build();
    return NimbusJwtEncoder.withSecretKey(SecurityConfig.JWT_KEY).build()
        .encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
  }
}
 OUTPUT - testing JWT-protected endpoints
   WWW-Authenticate: Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource"
   /api/me: {"authorities":["SCOPE_reports.read"],"name":"ann"}
   with a real token: {"authorities":["FACTOR_BEARER","SCOPE_reports.read","SCOPE_reports.write"],"name":"raj"}
   with a tampered token: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost/.well-known/oauth-protected-resource"
╷
└─ JUnit Jupiter ✔
   └─ ApiJwtTest ✔
      ├─ missingScopeIsForbidden() ✔
      ├─ noTokenGets401() ✔
      ├─ jwtPostProcessor() ✔
      ├─ realSignedTokenGoesThroughTheDecoder() ✔
      └─ writeScopeIsAllowed() ✔

[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         5 tests found           ]
[         0 tests skipped         ]
[         5 tests started         ]
[         0 tests aborted         ]
[         5 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • No token: 401, with a WWW-Authenticate: Bearer header that tells the client to send a bearer token, and where to find the resource's metadata.
  • The jwt() post processor: with the subject "ann" and the claim scope "reports.read", /api/me showed the name "ann" and the authority SCOPE_reports.read, derived from the claim as a real token's would be. jwt() without any setup has the subject "user" and the authority SCOPE_read, which is not enough for the POST: 403. With authorities(...) set to SCOPE_reports.write, the POST returned 201.
  • A real token: the signed token went through the real decoder, and its two scopes became two authorities. It also got the authority FACTOR_BEARER, which Spring Security 7 adds to record how the user authenticated - here with a bearer token - and which rules requiring several authentication factors can check. The jwt() post processor does not add it, a reminder that it skips the real authentication.
  • A tampered token: one changed character in the signature, and the decoder rejected it with 401, "Invalid signature", in the WWW-Authenticate header.

Testing method security

@PreAuthorize rules protect a bean, whoever calls it, so they are tested by calling the bean directly, in a test with the application context. @WithMockUser works here too: it is not tied to MockMvc, but sets the SecurityContext of the test's thread. @WithUserDetails("raj") loads the user from the application's UserDetailsService instead, with the roles the application really gives him -
src/test/java/.../ReportServiceSecurityTest.java - method security
//ReportServiceSecurityTest.java
package com.javabrahman.testing.security;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.test.context.support.WithUserDetails;

@SpringBootTest
class ReportServiceSecurityTest {

  @Autowired
  ReportService reports;

  @Test
  @WithMockUser("ann")
  void ownReportsAreAllowed() {
    assertThat(reports.reportsOf("ann")).containsExactly("ann-q1", "ann-q2");
  }

  @Test
  @WithMockUser("ann")
  void othersReportsAreDenied() {
    assertThatThrownBy(() -> reports.reportsOf("raj"))
        .isInstanceOf(AccessDeniedException.class)
        .satisfies(e -> System.out.println("   ann reads raj's reports: " + e.getClass().getSimpleName() + ": " + e.getMessage()));
  }

  @Test
  @WithUserDetails("raj")                      // loads "raj" from the real UserDetailsService
  void adminMayDoBoth() {
    System.out.println("   raj's authorities: " + SecurityContextHolder.getContext().getAuthentication().getAuthorities());
    assertThat(reports.reportsOf("ann")).hasSize(2);
    assertThat(reports.delete(7)).isEqualTo("deleted report 7");
  }

  @Test
  @WithMockUser(username = "ann", roles = "USER")
  void userCannotDelete() {
    assertThatThrownBy(() -> reports.delete(7)).isInstanceOf(AccessDeniedException.class);
  }

  @Test
  void withoutAUserNothingIsAllowed() {
    assertThatThrownBy(() -> reports.delete(7))
        .isInstanceOf(AuthenticationCredentialsNotFoundException.class)
        .satisfies(e -> System.out.println("   no user: " + e.getMessage()));
    assertThatThrownBy(() -> reports.reportsOf("ann"))          // the expression itself reads authentication.name
        .isInstanceOf(IllegalArgumentException.class)
        .satisfies(e -> System.out.println("   no user, reportsOf: " + e.getMessage()));
  }
}
 OUTPUT - method security
   raj's authorities: [ROLE_ADMIN, ROLE_USER]
   no user: An Authentication object was not found in the SecurityContext
   no user, reportsOf: Failed to evaluate expression '#owner == authentication.name or hasRole('ADMIN')'
   ann reads raj's reports: AuthorizationDeniedException: Access Denied
╷
└─ JUnit Jupiter ✔
   └─ ReportServiceSecurityTest ✔
      ├─ adminMayDoBoth() ✔
      ├─ withoutAUserNothingIsAllowed() ✔
      ├─ othersReportsAreDenied() ✔
      ├─ ownReportsAreAllowed() ✔
      └─ userCannotDelete() ✔

[         2 containers found      ]
[         0 containers skipped    ]
[         2 containers started    ]
[         0 containers aborted    ]
[         2 containers successful ]
[         0 containers failed     ]
[         5 tests found           ]
[         0 tests skipped         ]
[         5 tests started         ]
[         0 tests aborted         ]
[         5 tests successful      ]
[         0 tests failed          ]

Explanation of the output

  • Own and others' reports: ann read her own reports, and reading raj's failed with AuthorizationDeniedException: Access Denied, the exception thrown by method security when a rule says no. It is a subclass of AccessDeniedException, which the test checked.
  • @WithUserDetails: raj's authorities, [ROLE_ADMIN, ROLE_USER], came from the real user store, and as an admin he could read ann's reports and delete one. If the application's users or roles changed, this test would notice, while a @WithMockUser test would not.
  • No user at all: delete(), with the rule hasRole('ADMIN'), failed with "An Authentication object was not found in the SecurityContext". reportsOf() failed differently: its expression reads authentication.name before anything else, and with no authentication the expression itself could not be evaluated, so the call ended with an IllegalArgumentException. The call was still refused, but with an error instead of a security exception - only a test with no user reveals such an expression.

Good practice for security tests

  • Import the real configuration into web slices, so that the tests check the application's rules rather than Spring Boot's defaults.
  • Test the refusals, not only the successes: anonymous, wrong role, missing scope, missing CSRF token. A rule that lets everyone in passes every "happy path" test.
  • Use ready users for rules, real credentials for logins: most tests use @WithMockUser, user() or jwt(); a few send a real password or a real signed token.
  • Test method security on the bean, since it protects the bean from every caller - a controller, a scheduled job or a message listener.

Summary

In this tutorial we tested a secured application. In a @WebMvcTest with the real security configuration, @WithMockUser and user() ran requests as ready users, showing 401 for anonymous requests and 403 for a missing role, csrf() made a POST pass the CSRF check, and httpBasic() tested a real password. For the JWT-protected API, jwt() set the subject, claims and authorities of a token, and a real signed token, and a tampered one, went through the real decoder. Method security was tested on the service with @WithMockUser and @WithUserDetails, including a call with no user at all. The next tutorial tests the contract between two services: contract testing with Spring Cloud ContractClick to read tutorial: Contract Testing with Spring Cloud Contract.