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("<script>");
} 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.htmlSecurity 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());
}
}