Introduction
Security bugs are the ones you cannot afford. A single misconfigured endpoint, a forgotten role check, or an incorrect permission boundary can expose your entire user base to data breaches, privilege escalation, or unauthorized access. Testing Spring Security rules is not optional—it's fundamental to shipping code you can trust in production.
In this article, we'll walk through testing Spring Security rules: best practices explained, covering everything from verifying unauthenticated access is blocked to validating resource-level ownership checks. We'll use concrete examples and real production patterns that teams across e-commerce, SaaS, and ride-hailing platforms have relied on to catch security regressions before they reach users.
Test every endpoint unauthenticated → 401
The first line of defense is ensuring that protected endpoints reject requests without valid authentication. Every endpoint that requires authentication should return HTTP 401 Unauthorized when called without credentials. This seems obvious, but it's surprising how often this basic check is skipped in test suites.
Spring Security's test utilities make this straightforward. Use MockMvc with @WebMvcTest or @SpringBootTest to simulate HTTP requests without authentication headers or tokens.
@SpringBootTest
@AutoConfigureMockMvc
class AuthenticationRequiredTest {
@Autowired
private MockMvc mockMvc;
@Test
void unauthenticatedRequestToProtectedEndpointReturns401() throws Exception {
mockMvc.perform(get("/api/users/profile"))
.andExpect(status().isUnauthorized());
}
@Test
void unauthenticatedRequestToPublicEndpointReturns200() throws Exception {
mockMvc.perform(get("/api/public/health"))
.andExpect(status().isOk());
}
@Test
void invalidTokenReturns401() throws Exception {
mockMvc.perform(get("/api/users/profile")
.header("Authorization", "Bearer invalid-token"))
.andExpect(status().isUnauthorized());
}
}
The pattern here is clear: for every protected endpoint, verify the happy path returns success, then immediately verify the unauthenticated path returns 401. This catches configuration mistakes where an endpoint was accidentally left open or an authentication filter wasn't applied correctly.
In production systems handling payments or personal data, we've seen teams add a separate test suite that cycles through their entire API surface and verifies every single endpoint either authenticates or is explicitly marked public. Automation catches drift quickly.
Wrong role → 403
Authentication answers "are you who you claim to be?" Authorization answers "are you allowed to do what you're asking?" A user might be correctly authenticated, but lack the role or permission to access a resource. Spring Security should return HTTP 403 Forbidden in these cases.
Use @WithMockUser or the JWT-based post-processor to assign specific roles, then verify that endpoints reject users with insufficient permissions.
@SpringBootTest
@AutoConfigureMockMvc
class RoleBasedAccessTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER")
void userWithUserRoleCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/reports"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void userWithAdminRoleCanAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/reports"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void userCanAccessUserEndpoint() throws Exception {
mockMvc.perform(get("/api/users/profile"))
.andExpect(status().isOk());
}
@Test
void unauthenticatedCannotAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/reports"))
.andExpect(status().isUnauthorized());
}
}
Notice we test three scenarios: correct role succeeds, wrong role fails with 403, and no authentication fails with 401. This pattern prevents regressions where an authorization check is accidentally removed or a role name is misspelled in @PreAuthorize or @Secured annotations.
In your Spring Security configuration, these rules live in your SecurityFilterChain:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasRole("USER")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter()))
);
return http.build();
}
Owner vs non-owner for resource rules
Role-based access is necessary but insufficient. Most applications need resource-level permissions: a user should only edit their own profile, view their own orders, or update their own settings. Testing this boundary is critical because it's where real user data leaks happen.
A user might be authenticated with valid role, but trying to access another user's resource. Your API should return 403 or 404 depending on your security posture (returning 404 leaks less information about what exists).
@SpringBootTest
@AutoConfigureMockMvc
class ResourceOwnershipTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private OrderRepository orderRepository;
@Test
@WithMockUser(username = "alice")
void userCanViewOwnProfile() throws Exception {
mockMvc.perform(get("/api/users/alice/profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("alice"));
}
@Test
@WithMockUser(username = "alice")
void userCannotViewAnotherUsersProfile() throws Exception {
mockMvc.perform(get("/api/users/bob/profile"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "alice")
void userCanUpdateOwnOrder() throws Exception {
Order order = new Order();
order.setId(1L);
order.setOwner("alice");
orderRepository.save(order);
mockMvc.perform(put("/api/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"cancelled\"}"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "bob")
void userCannotUpdateAnotherUsersOrder() throws Exception {
Order order = new Order();
order.setId(1L);
order.setOwner("alice");
orderRepository.save(order);
mockMvc.perform(put("/api/orders/1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"cancelled\"}"))
.andExpect(status().isForbidden());
}
}
This is where your business logic meets security. Your controller or service layer must verify the authenticated user's ID matches the resource owner before allowing modification:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderRepository orderRepository;
@PutMapping("/{id}")
public ResponseEntity updateOrder(
@PathVariable Long id,
@RequestBody OrderUpdateRequest req,
@AuthenticationPrincipal UserDetails user) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
if (!order.getOwner().equals(user.getUsername())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
order.setStatus(req.getStatus());
orderRepository.save(order);
return ResponseEntity.ok(order);
}
}
Test both the happy path (owner modifies own resource) and the failure path (non-owner blocked). In distributed systems handling millions of transactions, this check is where most data leaks originate—not from missing endpoints, but from forgotten ownership validation.
Use jwt() request post-processors for token claims
When testing JWT-based authentication (common in microservices and modern SaaS), @WithMockUser isn't sufficient. You need to test claims, expiration, signature validation, and custom authorities extracted from token payloads. Spring Security provides jwt() post-processor to build realistic JWT tokens for testing.
@SpringBootTest
@AutoConfigureMockMvc
class JwtAuthenticationTest {
@Autowired
private MockMvc mockMvc;
@Test
void validJwtAllowsAccess() throws Exception {
mockMvc.perform(get("/api/users/profile")
.with(jwt()
.jwt(jwt -> jwt
.claim("sub", "user123")
.claim("email", "user@example.com")
.claim("scope", "read write")
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isOk());
}
@Test
void jwtWithAdminClaimAllowsAdminAccess() throws Exception {
mockMvc.perform(get("/api/admin/reports")
.with(jwt()
.jwt(jwt -> jwt
.claim("sub", "admin123")
.claim("role", "ADMIN")
)
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))
))
.andExpect(status().isOk());
}
@Test
void jwtWithoutRequiredClaimReturns403() throws Exception {
mockMvc.perform(get("/api/admin/reports")
.with(jwt()
.jwt(jwt -> jwt
.claim("sub", "user123")
.claim("role", "USER")
)
.authorities(new SimpleGrantedAuthority("ROLE_USER"))
))
.andExpect(status().isForbidden());
}
@Test
void expiredTokenReturns401() throws Exception {
Instant past = Instant.now().minus(Duration.ofHours(1));
mockMvc.perform(get("/api/users/profile")
.with(jwt()
.jwt(jwt -> jwt
.claim("sub", "user123")
.expiresAt(past)
)
))
.andExpect(status().isUnauthorized());
}
}
The jwt() post-processor is part of Spring Security Test and allows fine-grained control over token properties. You can set custom claims, expiration times, and authorities to simulate real-world scenarios. This is especially important when your authorization logic depends on custom claims extracted from tokens—test that extraction and validation works as expected.
Your JWT converter should extract claims and map them to Spring Security authorities:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
String role = jwt.getClaimAsString("role");
if (role != null) {
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
}
return List.of();
});
return converter;
}
}
Common mistakes
- Testing only the happy path. Security tests live in the negative space. Always pair success tests with failure tests for the same endpoint.
- Forgetting to test 401 vs 403 distinction. 401 means no auth provided; 403 means auth exists but insufficient. Testing both prevents confusion and leaks.
- Using real JWTs in tests. Generate test tokens programmatically with
jwt()post-processor. Real tokens expire, change signature, and couple tests to external services. - Skipping ownership checks in unit tests. These are often checked in service or repository layers. Test them explicitly; don't assume integration tests will catch them.
- Not testing role hierarchy or custom authorities. If your app uses role hierarchies (ADMIN includes USER permissions) or custom authority implementations, test that too.
- Ignoring CORS, CSRF, or other security headers. Spring Security handles these, but test that
OPTIONSrequests behave correctly and that state-changing operations require CSRF tokens if applicable.
Key takeaways
- Test every protected endpoint without authentication and expect 401. Test every public endpoint and expect success. This catches configuration mistakes early.
- For role-based access, test three scenarios: correct role succeeds, wrong role fails with 403, no auth fails with 401.
- Resource ownership checks are where data leaks happen. Write explicit tests for user A accessing user B's resource and verify it returns 403 or 404.
- Use Spring Security's
jwt()post-processor to build test tokens with custom claims. Avoid real JWTs in tests. - Structure security tests as negative tests first—what should fail?—then positive tests—what should succeed? Reverse the usual order.
- Automate a sweep of your entire API surface to verify every endpoint is either protected or explicitly public. Run this on CI to catch drift.
- Security tests are not optional. Budget time for them the same way you budget time for database migration tests or payment processing tests.






Comments
Loading comments…