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

  • @WithMockUser injects a mock user with specified roles — no database needed
  • @WithUserDetails loads a real user from UserDetailsService — needs test data
  • jwt() from spring-security-test injects a mock JWT — no real token signing needed
  • SecurityMockMvcRequestPostProcessors methods (user(), httpBasic(), jwt()) work inline on requests
  • Custom @WithSecurityContext annotations for complex authentication objects
  • Test @PreAuthorize at 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.