Spring BootSecurity Testing
Spring Boot

Security Testing

Security testing verifies that a microservice correctly enforces authentication, authorisation, input validation, and data protection. It covers unit-level security (method security annotations), integration-level security (HTTP endpoint protection), and vulnerability scanning (OWASP dependency check, static analysis). Security tests are part of the standard test suite — not an afterthought.

Authentication Tests

Every protected endpoint must be tested with three cases: unauthenticated (no credentials), authenticated with insufficient role (403), and authenticated with correct role (200). Missing any one of these leaves a security gap that may not be caught until production.
Java
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class AuthenticationTest {

    @Autowired  private MockMvc mockMvc;
    @Autowired  private ObjectMapper objectMapper;
    @MockBean   private UserService userService;
    @MockBean   private JwtTokenProvider jwtTokenProvider;

    // ── Unauthenticated access: ───────────────────────────────────────
    @Test
    void protectedEndpoint_noToken_returns401() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    // ── Invalid / expired token: ──────────────────────────────────────
    @Test
    void protectedEndpoint_expiredToken_returns401() throws Exception {
        doThrow(new ExpiredJwtException(null, null, "Token expired"))
            .when(jwtTokenProvider).validateToken("expired.token.here");

        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer expired.token.here"))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.error")
                .value(containsString("expired")));
    }

    // ── Malformed token: ──────────────────────────────────────────────
    @Test
    void protectedEndpoint_malformedToken_returns401() throws Exception {
        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer not.a.valid.jwt"))
            .andExpect(status().isUnauthorized());
    }

    // ── Valid token, wrong role: ──────────────────────────────────────
    @Test
    @WithMockUser(roles = "USER")
    void adminEndpoint_userRole_returns403() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isForbidden());
    }

    // ── Valid token, correct role: ────────────────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void adminEndpoint_adminRole_returns200() throws Exception {
        when(userService.findAll()).thenReturn(List.of());
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }

    // ── JWT via MockMvc post-processor: ───────────────────────────────
    @Test
    void protectedEndpoint_validJwt_returns200() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users")
                .with(jwt()
                    .jwt(j -> j
                        .claim("sub", "user-123")
                        .claim("role", "ADMIN"))
                    .authorities(
                        new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isOk());
    }
}

Authorisation and Method Security Tests

Authorisation tests verify that users can only access data they own and only perform operations their role permits. Test ownership checks, role boundaries, and privilege escalation attempts — a USER must not be able to access another user's data or perform ADMIN operations by guessing IDs.
Java
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class AuthorisationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired private MockMvc mockMvc;
    @Autowired private UserRepository userRepository;
    @Autowired private OrderRepository orderRepository;

    private User alice;
    private User bob;

    @BeforeEach
    void setUp() {
        orderRepository.deleteAll();
        userRepository.deleteAll();
        alice = userRepository.save(
            User.builder().name("Alice").email("alice@x.com").build());
        bob = userRepository.save(
            User.builder().name("Bob").email("bob@x.com").build());
    }

    // ── Owner can access own resource: ────────────────────────────────
    @Test
    @WithMockUser(username = "#{alice.id}", roles = "USER")
    void getUser_ownResource_returns200() throws Exception {
        mockMvc.perform(get("/api/users/" + alice.getId()))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.email").value("alice@x.com"));
    }

    // ── User cannot access another user's resource: ───────────────────
    @Test
    void getUser_anotherUsersResource_returns403() throws Exception {
        mockMvc.perform(get("/api/users/" + bob.getId())
                .with(jwt().jwt(j -> j.subject(
                    alice.getId().toString()))
                    .authorities(
                        new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }

    // ── ADMIN can access any user: ────────────────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void getUser_adminAccessingAnyUser_returns200() throws Exception {
        mockMvc.perform(get("/api/users/" + bob.getId()))
            .andExpect(status().isOk());
    }

    // ── IDOR (Insecure Direct Object Reference) — order ownership: ────
    @Test
    void getOrder_differentUsersOrder_returns403() throws Exception {
        Order aliceOrder = orderRepository.save(
            Order.builder().userId(alice.getId()).build());

        // Bob tries to access Alice's order by guessing the ID:
        mockMvc.perform(get("/api/orders/" + aliceOrder.getId())
                .with(jwt().jwt(j -> j.subject(
                    bob.getId().toString()))
                    .authorities(
                        new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }

    // ── Privilege escalation — USER cannot call ADMIN endpoint: ───────
    @Test
    void deleteUser_asUser_returns403() throws Exception {
        mockMvc.perform(delete("/api/users/" + alice.getId())
                .with(jwt().jwt(j -> j.subject(
                    alice.getId().toString()))
                    .authorities(
                        new SimpleGrantedAuthority("ROLE_USER"))))
            .andExpect(status().isForbidden());
    }

    // ── Method security test — service layer: ─────────────────────────
    @Test
    @WithMockUser(roles = "USER")
    void methodSecurity_userCallingAdminMethod_throwsAccessDenied() {
        assertThatThrownBy(() -> userService.deleteAll())
            .isInstanceOf(AccessDeniedException.class);
    }
}

Input Validation and Injection Tests

Input validation tests verify that the application correctly rejects malicious input. SQL injection, XSS payloads, and oversized inputs must all be rejected before reaching the service or database layer. Spring's @Valid and parameterised JPA queries prevent most injection attacks — these tests confirm the protection is actually in place.
Java
@SpringBootTest
@AutoConfigureMockMvc
class InputValidationSecurityTest {

    @Autowired  private MockMvc mockMvc;
    @Autowired  private ObjectMapper objectMapper;

    // ── SQL injection in path variable: ──────────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void sqlInjection_inPathVariable_returns400OrSafe() throws Exception {
        // Parameterised queries prevent injection — should return 400
        // (invalid ID format) not 500 (query error) or 200 (data leak):
        mockMvc.perform(get("/api/users/1 OR 1=1"))
            .andExpect(status().is4xxClientError());

        mockMvc.perform(get("/api/users/1; DROP TABLE users;--"))
            .andExpect(status().is4xxClientError());
    }

    // ── SQL injection in request parameter: ──────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void sqlInjection_inRequestParam_isSanitised() throws Exception {
        // Spring Data JPA parameterises queries — injection returns
        // empty results, not a database error or data from other users:
        mockMvc.perform(get("/api/users")
                .param("name", "' OR '1'='1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content").isEmpty());
    }

    // ── XSS in request body: ─────────────────────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void xssPayload_inRequestBody_isRejectedOrEscaped() throws Exception {
        String xssName = "<script>alert('xss')</script>";

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(
                    new CreateUserRequest(
                        xssName, "xss@x.com", "password123"))))
            .andExpect(result -> {
                int status = result.getResponse().getStatus();
                if (status == 201) {
                    // If accepted — must be escaped in response:
                    String body = result.getResponse()
                        .getContentAsString();
                    assertThat(body)
                        .doesNotContain("<script>")
                        .contains("&lt;script&gt;");
                } else {
                    // Rejected outright — also acceptable:
                    assertThat(status).isEqualTo(400);
                }
            });
    }

    // ── Oversized payload (DoS protection): ──────────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void oversizedPayload_returns413OrRejects() throws Exception {
        // Generate a 10MB payload:
        String hugeName = "A".repeat(10 * 1024 * 1024);

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"name":"" + hugeName + "","
                    + ""email":"x@x.com","
                    + ""password":"pass1234"}"))
            .andExpect(status().is4xxClientError());
                // 400 (validation) or 413 (payload too large)
    }

    // ── Mass assignment — reject unknown fields: ──────────────────────
    @Test
    @WithMockUser(roles = "USER")
    void massAssignment_extraFieldsIgnored() throws Exception {
        // Attempt to set 'role' to ADMIN via extra JSON field:
        String body = """
            {
              "name":     "Hacker",
              "email":    "hack@x.com",
              "password": "password123",
              "role":     "ADMIN",
              "id":       999
            }
            """;

        MvcResult result = mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isCreated())
            .andReturn();

        String response = result.getResponse().getContentAsString();
        // Role must default to USER, not whatever was sent:
        assertThat(response).contains(""role":"USER"");
        assertThat(response).doesNotContain(""role":"ADMIN"");
    }
}

JWT Security Tests

JWT-specific security tests verify that the token validation is strict — expired tokens, tokens signed with the wrong key, algorithm confusion attacks (e.g. none algorithm), and tokens with tampered claims must all be rejected. These edge cases are often missed in functional testing.
Java
@SpringBootTest
@AutoConfigureMockMvc
class JwtSecurityTest {

    @Autowired  private MockMvc mockMvc;

    @Value("${jwt.secret}")
    private String jwtSecret;

    // ── Valid token accepted: ─────────────────────────────────────────
    @Test
    void validToken_returns200() throws Exception {
        String token = buildToken(b -> b
            .subject("user-1")
            .claim("role", "USER")
            .expiration(Date.from(Instant.now().plusSeconds(3600))));

        mockMvc.perform(get("/api/users/1")
                .header("Authorization", "Bearer " + token))
            .andExpect(status().isOk());
    }

    // ── Expired token rejected: ───────────────────────────────────────
    @Test
    void expiredToken_returns401() throws Exception {
        String expired = buildToken(b -> b
            .subject("user-1")
            .claim("role", "ADMIN")
            .expiration(Date.from(Instant.now().minusSeconds(1))));

        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + expired))
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.error")
                .value(containsString("expired")));
    }

    // ── Token signed with wrong key: ─────────────────────────────────
    @Test
    void wrongSigningKey_returns401() throws Exception {
        String wrongKey = buildTokenWithKey(
            "completelydifferentsecretkeyfortesting123456",
            b -> b.subject("user-1").claim("role", "ADMIN")
                  .expiration(Date.from(Instant.now().plusSeconds(3600))));

        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + wrongKey))
            .andExpect(status().isUnauthorized());
    }

    // ── Algorithm confusion — "none" algorithm attack: ────────────────
    @Test
    void noneAlgorithmToken_returns401() throws Exception {
        // Craft a token with alg:none — no signature required:
        String header  = Base64.getEncoder().encodeToString(
            "{"alg":"none","typ":"JWT"}".getBytes());
        String payload = Base64.getEncoder().encodeToString(
            "{"sub":"admin","role":"ADMIN","
            + ""exp":9999999999}".getBytes());
        String noneToken = header + "." + payload + ".";

        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + noneToken))
            .andExpect(status().isUnauthorized());
    }

    // ── Tampered claims — modified payload: ───────────────────────────
    @Test
    void tamperedPayload_returns401() throws Exception {
        String valid = buildToken(b -> b
            .subject("user-1")
            .claim("role", "USER")
            .expiration(Date.from(Instant.now().plusSeconds(3600))));

        // Split and replace payload with elevated role:
        String[] parts = valid.split("\.");
        String tamperedPayload = Base64.getEncoder().encodeToString(
            ("{"sub":"user-1","role":"ADMIN","
            + ""exp":9999999999}").getBytes());
        String tampered = parts[0] + "." + tamperedPayload + "." + parts[2];

        mockMvc.perform(get("/api/users")
                .header("Authorization", "Bearer " + tampered))
            .andExpect(status().isUnauthorized());
    }

    private String buildToken(
            Consumer<JwtBuilder> customiser) {
        return buildTokenWithKey(jwtSecret, customiser);
    }

    private String buildTokenWithKey(String secret,
            Consumer<JwtBuilder> customiser) {
        JwtBuilder builder = Jwts.builder()
            .signWith(Keys.hmacShaKeyFor(secret.getBytes()));
        customiser.accept(builder);
        return builder.compact();
    }
}

OWASP Dependency Check and Static Analysis

Vulnerability scanning detects known CVEs in third-party dependencies and code-level security issues. OWASP Dependency Check scans the classpath for dependencies with known vulnerabilities. SpotBugs with the Find Security Bugs plugin performs static analysis for common security mistakes. Both integrate with Maven and fail the build when issues above a configured severity threshold are found.
XML
<!-- pom.xml — OWASP Dependency Check plugin: -->
<!-- <plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>10.0.3</version>
    <configuration>
        <failBuildOnCVSS>7</failBuildOnCVSS>
        <suppressionFile>owasp-suppressions.xml</suppressionFile>
        <formats>HTML,JSON</formats>
    </configuration>
    <executions>
        <execution>
            <goals><goal>check</goal></goals>
        </execution>
    </executions>
</plugin> -->

<!-- SpotBugs with Find Security Bugs plugin: -->
<!-- <plugin>
    <groupId>com.github.spotbugs</groupId>
    <artifactId>spotbugs-maven-plugin</artifactId>
    <version>4.8.5.0</version>
    <dependencies>
        <dependency>
            <groupId>com.h3xstream.findsecbugs</groupId>
            <artifactId>findsecbugs-plugin</artifactId>
            <version>1.13.0</version>
        </dependency>
    </dependencies>
    <configuration>
        <plugins>
            <plugin>
                <groupId>com.h3xstream.findsecbugs</groupId>
                <artifactId>findsecbugs-plugin</artifactId>
                <version>1.13.0</version>
            </plugin>
        </plugins>
        <effort>Max</effort>
        <threshold>Medium</threshold>
        <failOnError>true</failOnError>
    </configuration>
</plugin> -->

// ── Security issues Find Security Bugs detects: ───────────────────────
//
// SQL_INJECTION          → String concatenation in SQL queries
// XSS_SERVLET            → Unescaped user input in HTTP response
// HARD_CODE_PASSWORD     → Literal passwords / secrets in code
// WEAK_ALGORITHM         → MD5 / SHA1 usage for password hashing
// PATH_TRAVERSAL         → User input used in file path construction
// PREDICTABLE_RANDOM     → java.util.Random used for security tokens
// TRUST_BOUNDARY_VIOLATION → Untrusted data stored in session
// INSECURE_COOKIE        → Cookie without Secure / HttpOnly flag

// ── owasp-suppressions.xml — suppress false positives: ───────────────
// <?xml version="1.0" encoding="UTF-8"?>
// <suppressions>
//     <suppress>
//         <notes>
//           Test scope only — not deployed to production
//         </notes>
//         <gav regex="true">.*:h2:.*</gav>
//         <cve>CVE-2022-45868</cve>
//     </suppress>
// </suppressions>

// ── GitHub Actions security scanning: ────────────────────────────────
// name: Security Scan
// on: [push, pull_request]
// jobs:
//   security:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//
//       - name: OWASP Dependency Check
//         run: mvn org.owasp:dependency-check-maven:check
//              -DfailBuildOnCVSS=7
//
//       - name: SpotBugs Security Analysis
//         run: mvn spotbugs:check
//
//       - name: Upload OWASP Report
//         uses: actions/upload-artifact@v4
//         with:
//           name: owasp-report
//           path: target/dependency-check-report.html

Security Headers and HTTPS Tests

Security headers protect clients from clickjacking, MIME sniffing, cross-site scripting, and information disclosure. Spring Security sets sensible defaults — these tests verify the headers are present and correctly configured, and that sensitive data such as passwords and internal stack traces never appear in responses.
Java
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class SecurityHeadersTest {

    @Autowired  private MockMvc mockMvc;
    @MockBean   private UserService userService;

    // ── Verify required security headers are present: ─────────────────
    @Test
    @WithMockUser
    void response_containsRequiredSecurityHeaders() throws Exception {
        mockMvc.perform(get("/api/users/1"))
            // Prevent clickjacking:
            .andExpect(header().string(
                "X-Frame-Options", "DENY"))
            // Prevent MIME type sniffing:
            .andExpect(header().string(
                "X-Content-Type-Options", "nosniff"))
            // XSS protection (legacy browsers):
            .andExpect(header().string(
                "X-XSS-Protection", "0"))
            // HSTS — only HTTPS for 1 year:
            .andExpect(header().exists(
                "Strict-Transport-Security"))
            // Cache control — no caching of authenticated responses:
            .andExpect(header().string(
                "Cache-Control",
                containsString("no-cache")));
    }

    // ── Server version must not be disclosed: ─────────────────────────
    @Test
    @WithMockUser
    void response_doesNotExposeServerVersion() throws Exception {
        mockMvc.perform(get("/api/users/1"))
            .andExpect(header().doesNotExist("Server"))
            .andExpect(header().doesNotExist("X-Powered-By"))
            .andExpect(header().doesNotExist(
                "X-Application-Context"));
    }

    // ── Error responses must not expose stack traces: ─────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void errorResponse_doesNotExposeStackTrace() throws Exception {
        when(userService.findById(99L))
            .thenThrow(new RuntimeException("DB connection failed"));

        String body = mockMvc.perform(get("/api/users/99"))
            .andExpect(status().isInternalServerError())
            .andReturn()
            .getResponse()
            .getContentAsString();

        assertThat(body)
            .doesNotContain("at com.example")       // no stack trace
            .doesNotContain("RuntimeException")     // no exception class
            .doesNotContain("DB connection failed") // no internal message
            .contains("Internal server error");     // only safe message
    }

    // ── Password never returned in any response: ──────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void userResponse_neverIncludesPassword() throws Exception {
        when(userService.findAll()).thenReturn(List.of(
            UserResponse.builder()
                .id(1L).name("Alice").email("alice@x.com").build()
        ));

        String body = mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk())
            .andReturn()
            .getResponse()
            .getContentAsString();

        assertThat(body)
            .doesNotContain("password")
            .doesNotContain("passwordHash");
    }

    // ── CSRF — stateful endpoints require CSRF token: ─────────────────
    @Test
    void stateChangingRequest_withoutCsrfToken_returns403() throws Exception {
        // For session-based (non-JWT) endpoints only:
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"name":"x","email":"x@x.com"}"))
            .andExpect(status().isForbidden());
    }
}