OWASP Top 10 for Spring Boot: Real Vulnerabilities and How to Fix Them
The OWASP Top 10 lists the most critical web application security risks. Spring Boot apps have their own common failure patterns: exposed Actuator endpoints, secrets in properties files, SQL built from string concatenation, and Spring Security misconfiguration.
This guide covers the vulnerabilities that actually appear in Spring Boot applications and how to fix each one.
1. SQL Injection
SQL injection remains one of the most critical vulnerabilities. It allows attackers to manipulate database queries.
Vulnerable code
// DANGEROUS — concatenating user input into SQL
@Repository
public class OrderRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Order> findByStatus(String status) {
// If status = "PENDING' OR '1'='1", this returns ALL orders
String sql = "SELECT * FROM orders WHERE status = '" + status + "'";
return jdbcTemplate.query(sql, orderRowMapper);
}
}
Fixed code
// Safe: parameterized query — the driver handles escaping
public List<Order> findByStatus(String status) {
return jdbcTemplate.query(
"SELECT * FROM orders WHERE status = ?",
orderRowMapper,
status // parameter, not concatenated
);
}
// Safe: Spring Data JPA with @Query (named parameters)
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatus(@Param("status") OrderStatus status);
// Safe: Spring Data derived queries (no SQL string at all)
List<Order> findByStatus(OrderStatus status);
Rule: never concatenate user input into SQL. Use parameterized queries or JPA. The driver handles escaping.
JPQL injection (Spring Data)
JPQL is also injectable if you build query strings dynamically:
// VULNERABLE
@Query("SELECT o FROM Order o WHERE o." + fieldName + " = :value")
// Safe: use Criteria API or Specifications for dynamic queries
Specification<Order> spec = (root, query, cb) ->
cb.equal(root.get(fieldName), value);
2. Broken Authentication
Problem: weak password hashing
// VULNERABLE — MD5 is broken, SHA-256 without salt is insufficient
String hashedPassword = DigestUtils.md5Hex(password);
// Safe: BCrypt with strength 12
PasswordEncoder encoder = new BCryptPasswordEncoder(12);
String hashedPassword = encoder.encode(password);
boolean matches = encoder.matches(rawPassword, hashedPassword);
Spring Security auto-configures BCrypt when you add the starter. Always use the PasswordEncoder bean — don’t create your own hashing logic.
Problem: JWT vulnerabilities
// VULNERABLE — algorithm confusion attack: accepts HS256 when RS256 expected
Jwts.parser().parseClaimsJws(token); // no algorithm restriction
// Safe: explicitly specify allowed algorithms
Jwts.parser()
.verifyWith(signingKey) // set the verification key
.requireAlgorithm(Jwts.SIG.RS256) // only accept RS256
.build()
.parseSignedClaims(token);
Also validate JWT claims:
Jwts.parser()
.verifyWith(signingKey)
.requireAudience("order-service") // validate audience
.requireIssuer("https://auth.example.com") // validate issuer
.clockSkewSeconds(30) // allow 30s clock skew
.build()
.parseSignedClaims(token);
3. Exposed Actuator Endpoints
Actuator endpoints expose application internals. In 2024, exposed /actuator/env was listed as a common vulnerability in enterprise Spring Boot applications — it reveals environment variables, database URLs, and API keys.
Vulnerable configuration
# DANGEROUS — exposes everything including secrets
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
Secure configuration
management:
server:
port: 8081 # separate port — not exposed to the internet
endpoints:
web:
exposure:
include: health, prometheus # only expose what's needed
endpoint:
health:
show-details: never # no connection details publicly
env:
keys-to-sanitize: "password,secret,key,token,credentials,vcap_services"
// If management port must be the same port, restrict with Spring Security
@Configuration
@Order(1)
public class ActuatorSecurityConfig {
@Bean
public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.requestMatchers(EndpointRequest.to(PrometheusEndpoint.class))
.hasIpAddress("10.0.0.0/8") // monitoring server IP range
.anyRequest().hasRole("ADMIN")
)
.build();
}
}
4. Sensitive Data Exposure
Problem: secrets in properties files
# DANGEROUS — committed to version control
spring:
datasource:
password: myS3cr3tP@ssword
security:
oauth2:
client:
registration:
google:
client-secret: abc123xyz
Solution: Spring Cloud Vault
# application.yaml — no secrets here
spring:
config:
import: vault://
spring:
cloud:
vault:
host: vault.example.com
port: 8200
authentication: KUBERNETES # use K8s service account token
kubernetes:
role: order-service
Vault fetches the secrets at startup. They never appear in source code or environment variables visible via /actuator/env.
Solution: Kubernetes Secrets + Spring Boot
# application.yaml
spring:
datasource:
password: ${DB_PASSWORD} # resolved from env var
# k8s deployment
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
Mask sensitive fields in logs
// Mask sensitive request/response fields
@Component
public class LoggingFilter extends OncePerRequestFilter {
private static final Set<String> SENSITIVE_HEADERS = Set.of(
"Authorization", "X-Api-Key", "Cookie"
);
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
// Log headers but mask sensitive ones
Collections.list(request.getHeaderNames()).forEach(name -> {
String value = SENSITIVE_HEADERS.contains(name) ? "***" : request.getHeader(name);
log.debug("Header {}: {}", name, value);
});
filterChain.doFilter(request, response);
}
}
5. Broken Access Control
Problem: missing method-level authorization
// VULNERABLE — any authenticated user can access any user's orders
@GetMapping("/orders/{userId}")
public List<Order> getOrders(@PathVariable Long userId) {
return orderService.findByUserId(userId); // no ownership check
}
Fixed: verify ownership
@GetMapping("/orders/{userId}")
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public List<Order> getOrders(@PathVariable Long userId) {
return orderService.findByUserId(userId);
}
Or enforce in the service:
@Service
public class OrderService {
public List<Order> findByUserId(Long userId) {
Long currentUserId = SecurityContextHolder.getContext()
.getAuthentication().getPrincipal().getId();
if (!currentUserId.equals(userId) && !isAdmin()) {
throw new AccessDeniedException("Access denied to user " + userId + "'s orders");
}
return orderRepository.findByUserId(userId);
}
}
Enable method security
@Configuration
@EnableMethodSecurity // Spring Security 6+ (replaces @EnableGlobalMethodSecurity)
public class SecurityConfig {
// @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter now work
}
Spring Security 7 changes (Spring Boot 4)
// Spring Security 6 — lambda DSL required
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
// Spring Security 7 removed authorizeRequests() (non-lambda)
// and introduced stricter defaults: CSRF enabled for APIs too
6. Security Misconfiguration
Problem: CORS misconfiguration
// VULNERABLE — allows any origin
@CrossOrigin(origins = "*")
@RestController
public class OrderController { }
// Safe: restrict to your frontend domain
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
}
Problem: disabled CSRF for APIs
Spring Security enables CSRF protection by default. It’s sometimes disabled entirely for REST APIs:
// VULNERABLE — completely disabled
http.csrf(csrf -> csrf.disable());
// Better: disable only for API endpoints (stateless JWT)
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/**") // stateless JWT, no CSRF needed
// Keep CSRF for web form endpoints
);
If your REST API uses JWT Bearer tokens (stateless), CSRF is not needed — CSRF attacks require cookies. If you’re using cookie-based sessions, keep CSRF enabled.
Security headers
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'self'; script-src 'self'; style-src 'self'"))
.referrerPolicy(referrer ->
referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN))
.permissionsPolicy(permissions ->
permissions.policy("camera=(), microphone=(), geolocation=()"))
)
.build();
}
}
7. Vulnerable Dependencies
Spring Boot’s parent POM manages dependency versions, but you can override them:
# Check for vulnerable dependencies with OWASP dependency check
mvn org.owasp:dependency-check-maven:check
# Or with Trivy
trivy fs --security-checks vuln .
Add to CI pipeline:
# GitHub Actions
- name: Check for vulnerable dependencies
run: ./mvnw org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7
Fails the build if any CVE with CVSS score ≥ 7 is found.
8. XSS (Cross-Site Scripting)
For REST APIs returning JSON, XSS is less common (browsers don’t execute JSON as HTML). But if you render HTML with Thymeleaf:
<!-- SAFE — Thymeleaf automatically escapes HTML -->
<p th:text="${userInput}">...</p>
<!-- VULNERABLE — th:utext renders raw HTML -->
<p th:utext="${userInput}">...</p>
For REST APIs, add Content Security Policy headers (see security headers above) and validate input:
// Sanitize HTML input if you must accept and display it
@Autowired
private HtmlUtils htmlUtils;
public String sanitizeInput(String input) {
return HtmlUtils.htmlEscape(input); // escapes < > & " '
}
9. Insecure Deserialization
Never deserialize untrusted data with Java serialization:
// VULNERABLE — Java ObjectInputStream can execute arbitrary code
ObjectInputStream ois = new ObjectInputStream(request.getInputStream());
Object obj = ois.readObject(); // RCE if the stream is malicious
// Safe: use JSON with a typed class
@RequestBody OrderRequest request // Spring MVC + Jackson — safe
For Jackson, disable polymorphic deserialization:
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// Never enable: mapper.enableDefaultTyping() — unsafe polymorphic deserialization
// Instead, use @JsonTypeInfo on specific classes where needed
return mapper;
}
Security Checklist
Before every deployment
- No secrets in
application.propertiesor environment variables — use Vault/K8s Secrets - Actuator on separate port, only
healthandprometheusexposed publicly - CORS restricted to specific origins
- SQL queries use parameterized values, not concatenation
- Method security enabled, ownership checks on resource endpoints
- Security headers configured (CSP, X-Frame-Options, Referrer-Policy)
- OWASP dependency check passes in CI
- Docker image scanned with Trivy
- JWT validation includes algorithm, audience, and issuer checks
- Sensitive fields masked in logs
Quick Reference
// Method security
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
// Password hashing
new BCryptPasswordEncoder(12).encode(password);
// Parameterized SQL
jdbcTemplate.query("SELECT * FROM orders WHERE status = ?", mapper, status);
// Security headers
http.headers(h -> h.frameOptions(f -> f.deny())
.contentSecurityPolicy(c -> c.policyDirectives("default-src 'self'")));
// CORS
registry.addMapping("/api/**").allowedOrigins("https://app.example.com");
Summary
The most common Spring Boot security failures are exposed Actuator endpoints, secrets in properties files, missing method-level authorization, and SQL injection from string concatenation. Fix them: put Actuator on a separate management port and expose only what’s needed; use Vault or Kubernetes Secrets; add @PreAuthorize ownership checks; use parameterized queries or JPA. Add OWASP dependency checks and Trivy image scanning to CI. Enable method security and configure restrictive CORS before deploying to production.
