Testing Secured Endpoints
Security tests verify that your endpoints behave correctly for different users, roles, and authentication states. This article covers the full toolkit — from simple annotations to custom security contexts.
Setup
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
spring-boot-starter-test includes this automatically.
@WithMockUser — Simple Role-Based Tests
The simplest way to run a test as an authenticated user:
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
// No authentication
@Test
void unauthenticatedUserIsRejected() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
// Default: username=user, password=password, role=USER
@Test
@WithMockUser
void authenticatedUserCanListOrders() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
// Specific roles
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteOrders() throws Exception {
doNothing().when(orderService).delete(any());
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(roles = "USER")
void regularUserCannotDeleteOrders() throws Exception {
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
// Specific authorities (permissions, not roles)
@Test
@WithMockUser(authorities = {"READ_ORDERS", "WRITE_ORDERS"})
void userWithPermissionsCanAccessOrders() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
}
@WithUserDetails — Using Real UserDetailsService
When you need a real UserDetails from your database (e.g., for tests that check custom properties):
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerWithRealUserTest {
@Autowired MockMvc mockMvc;
// @WithUserDetails loads the user from UserDetailsService
@Test
@WithUserDetails("alice@example.com")
void aliceCanAccessHerOrders() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk());
}
@Test
@WithUserDetails(value = "admin@example.com", userDetailsServiceBeanName = "adminUserDetailsService")
void adminCanAccessAllOrders() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk());
}
}
Requires the user to exist in your UserDetailsService — usually needs a test database with seed data.
jwt() — Testing OAuth2 Resource Servers
For JWT-secured endpoints:
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class JwtSecuredOrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
void shouldReturn401WithoutJwt() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAllowAccessWithValidJwt() throws Exception {
when(orderService.findAll(any())).thenReturn(Page.empty());
mockMvc.perform(get("/api/orders")
.with(jwt())) // default JWT with no specific claims
.andExpect(status().isOk());
}
@Test
void shouldAllowAdminToDeleteWithJwt() throws Exception {
doNothing().when(orderService).delete(any());
mockMvc.perform(delete("/api/orders/{id}", UUID.randomUUID())
.with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isNoContent());
}
@Test
void jwtWithCustomClaims() throws Exception {
UUID userId = UUID.randomUUID();
mockMvc.perform(get("/api/orders")
.with(jwt()
.jwt(builder -> builder
.subject(userId.toString())
.claim("preferred_username", "alice")
.claim("email", "alice@example.com")
.claim("realm_access", Map.of("roles", List.of("USER")))
.expiresAt(Instant.now().plusSeconds(3600))
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isOk());
}
}
SecurityMockMvcRequestPostProcessors — Inline Setup
For one-off tests where you don’t want an annotation:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
@Test
void testWithInlineUser() throws Exception {
mockMvc.perform(get("/api/orders")
.with(user("alice").roles("USER").password("password")))
.andExpect(status().isOk());
}
@Test
void testWithHttpBasic() throws Exception {
mockMvc.perform(get("/api/admin/orders")
.with(httpBasic("admin", "password")))
.andExpect(status().isOk());
}
@Test
void testWithAuthentication() throws Exception {
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
"alice",
"password",
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
mockMvc.perform(get("/api/orders")
.with(authentication(auth)))
.andExpect(status().isOk());
}
Custom Security Annotations
If you use @PreAuthorize with complex expressions, create custom annotations for cleaner tests:
// Custom annotation for tests
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(roles = "ADMIN")
public @interface WithAdminUser {}
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "alice", roles = "USER")
public @interface WithRegularUser {}
// Use in tests
@Test
@WithAdminUser
void adminCanAccessReports() throws Exception {
mockMvc.perform(get("/api/reports/revenue"))
.andExpect(status().isOk());
}
@Test
@WithRegularUser
void regularUserCannotAccessReports() throws Exception {
mockMvc.perform(get("/api/reports/revenue"))
.andExpect(status().isForbidden());
}
Custom WithSecurityContext Factory
For complex custom authentication objects:
// Custom annotation
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithUserPrincipalFactory.class)
public @interface WithUserPrincipal {
String id() default "550e8400-e29b-41d4-a716-446655440000";
String username() default "alice";
String[] roles() default {"USER"};
}
// Factory that creates the SecurityContext
public class WithUserPrincipalFactory
implements WithSecurityContextFactory<WithUserPrincipal> {
@Override
public SecurityContext createSecurityContext(WithUserPrincipal annotation) {
UserPrincipal principal = new UserPrincipal(
UUID.fromString(annotation.id()),
annotation.username(),
"password",
Arrays.stream(annotation.roles())
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList()
);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities()
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
return context;
}
}
// Usage
@Test
@WithUserPrincipal(id = "abc-123", username = "alice", roles = {"USER", "PREMIUM"})
void premiumUserCanAccessPremiumFeatures() throws Exception {
mockMvc.perform(get("/api/premium/features"))
.andExpect(status().isOk());
}
Testing Method Security (@PreAuthorize)
Test @PreAuthorize on service methods directly — without the web layer:
@SpringBootTest
@Testcontainers
class OrderServiceSecurityTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired OrderService orderService;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteAnyOrder() {
// should not throw
assertThatCode(() -> orderService.delete(existingOrderId()))
.doesNotThrowAnyException();
}
@Test
@WithMockUser(roles = "USER")
void regularUserCannotDeleteOrders() {
assertThatThrownBy(() -> orderService.delete(existingOrderId()))
.isInstanceOf(AccessDeniedException.class);
}
@Test
@WithUserDetails("alice")
void userCanOnlyAccessTheirOwnOrders() {
UUID aliceOrderId = persistOrderForUser("alice");
UUID bobOrderId = persistOrderForUser("bob");
// Alice can get her own order
assertThatCode(() -> orderService.findById(aliceOrderId))
.doesNotThrowAnyException();
// Alice cannot get Bob's order
assertThatThrownBy(() -> orderService.findById(bobOrderId))
.isInstanceOf(AccessDeniedException.class);
}
}
Integration Test: Full Security Stack
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class FullSecurityIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
@Autowired TestRestTemplate restTemplate;
private String adminToken;
private String userToken;
@BeforeEach
void setupTokens() {
// Login as admin
var adminLogin = new LoginRequest("admin", "admin123!");
LoginResponse adminResponse = restTemplate
.postForObject("/api/auth/login", adminLogin, LoginResponse.class);
adminToken = adminResponse.accessToken();
// Login as regular user
var userLogin = new LoginRequest("alice", "alice123!");
LoginResponse userResponse = restTemplate
.postForObject("/api/auth/login", userLogin, LoginResponse.class);
userToken = userResponse.accessToken();
}
@Test
void regularUserCannotAccessAdminEndpoints() {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(userToken);
ResponseEntity<Void> response = restTemplate.exchange(
"/api/admin/users",
HttpMethod.GET,
new HttpEntity<>(headers),
Void.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void adminCanAccessAdminEndpoints() {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(adminToken);
ResponseEntity<String> response = restTemplate.exchange(
"/api/admin/users",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void expiredTokenIsRejected() {
String expiredToken = generateExpiredToken("alice");
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(expiredToken);
ResponseEntity<String> response = restTemplate.exchange(
"/api/orders",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains("token-expired");
}
}
Security Test Checklist
For every secured endpoint, test:
- Unauthenticated request → 401
- Authenticated with insufficient role → 403
- Authenticated with correct role → expected success response
- Accessing another user’s resource → 403 (ownership check)
- Expired token → 401 with clear error message
- Malformed token → 401
@ParameterizedTest
@MethodSource("securityTestCases")
void securityMatrix(String endpoint, HttpMethod method, String role, int expectedStatus)
throws Exception {
MockHttpServletRequestBuilder request = switch (method) {
case GET -> get(endpoint);
case POST -> post(endpoint).content("{}").contentType(APPLICATION_JSON);
case DELETE -> delete(endpoint);
default -> throw new IllegalArgumentException();
};
if (role != null) {
request.with(user("test").roles(role));
}
mockMvc.perform(request)
.andExpect(status().is(expectedStatus));
}
static Stream<Arguments> securityTestCases() {
return Stream.of(
Arguments.of("/api/orders", "GET", null, 401),
Arguments.of("/api/orders", "GET", "USER", 200),
Arguments.of("/api/orders", "GET", "ADMIN", 200),
Arguments.of("/api/admin/users", "GET", "USER", 403),
Arguments.of("/api/admin/users", "GET", "ADMIN", 200),
Arguments.of("/api/orders/{id}", "DELETE", "USER", 403),
Arguments.of("/api/orders/{id}", "DELETE", "ADMIN", 204)
);
}
What You’ve Learned
@WithMockUserinjects a mock user with specified roles — no database needed@WithUserDetailsloads a real user fromUserDetailsService— needs test datajwt()fromspring-security-testinjects a mock JWT — no real token signing neededSecurityMockMvcRequestPostProcessorsmethods (user(),httpBasic(),jwt()) work inline on requests- Custom
@WithSecurityContextannotations for complex authentication objects - Test
@PreAuthorizeat the service level with@WithMockUser— no web layer needed - Always test the full matrix: unauthenticated, wrong role, correct role, ownership checks
This completes Part 5: Testing. You now have a complete testing foundation covering unit, repository, web layer, integration, and security tests.
What’s Next
You’ve completed Parts 1–5 of the Spring Boot Tutorial — the core of what every Spring Boot developer needs to know. The tutorial continues with:
Part 6: Caching and Performance
- Spring Cache Abstraction with Caffeine
- Redis Caching
- HTTP Caching with ETags
Part 7: Production-Ready Spring Boot
- Actuator and health checks
- Distributed tracing with Micrometer
- Prometheus + Grafana monitoring
- Graceful shutdown
Part 8: Microservices with Spring Boot
- Spring Cloud Gateway
- Service discovery
- Circuit breakers with Resilience4j
- Distributed transactions
Visit the Spring Boot Tutorial index to see all articles.