Designing Secure Spring Boot Applications in 2026
- java
- spring-boot
- security
- backend
Security advice for Spring Boot applications is everywhere. Most of it is either too abstract ("follow OWASP") or too narrow ("add this annotation"). This is what you actually need to do.
Dependency Hygiene
The spring-boot-starter-parent BOM pins transitive dependency versions to a tested, compatible set. This is genuinely useful — Spring's release process catches a lot of CVEs before you encounter them. But it doesn't catch everything.
Run OWASP Dependency-Check as part of your build:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>9.0.9</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS>
</configuration>
</plugin>
Run mvn dependency:tree regularly and look for libraries you don't recognise. Transitive dependencies are where surprises live. A library you pulled in two years ago for one utility method may now carry three CVEs.
Keep spring-boot-starter-parent up to date. Staying on a Spring Boot minor version for 18 months is how you end up with a critical Log4Shell-style scramble.
Secrets Management
Never put secrets in application.properties committed to Git. This sounds obvious. I have still seen it in three separate codebases this year, every time with the same explanation: "it's just dev config."
The minimum baseline: use environment variables at runtime and document which variables the application requires in a README or env.example file. Spring Boot picks up environment variables automatically — SPRING_DATASOURCE_URL maps to spring.datasource.url.
For production, use a secrets manager. Spring Cloud Vault integrates cleanly:
spring:
cloud:
vault:
uri: https://vault.internal:8200
token: ${VAULT_TOKEN}
kv:
enabled: true
backend: secret
default-context: myapp
At minimum, rotate your database credentials and JWT signing keys on a schedule. If you've never rotated them, the question isn't whether they've been compromised — it's whether you'd know.
Spring Security Baseline
The default Spring Security configuration is reasonable as a starting point but needs hardening for production. The critical points:
Actuator endpoints are a common exposure vector. Disable all actuator endpoints over the main port and expose only what you need on a management port:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/liveness").permitAll()
.requestMatchers("/actuator/health/readiness").permitAll()
.requestMatchers("/actuator/**").hasRole("ACTUATOR_ADMIN")
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable()); // Only for stateless API; keep CSRF for session-based apps
return http.build();
}
In application.properties:
management.server.port=9090
management.endpoints.web.exposure.include=health,info,metrics,prometheus
HTTP Hardening
CORS misconfiguration is the silent killer. I've reviewed APIs where allowedOrigins("*") combined with allowCredentials(true) was present in production — which is both a security vulnerability and technically invalid per the spec (browsers will reject it, but some configurations don't).
Be explicit:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://yourdomain.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
Add security headers via a filter or at the reverse proxy level. HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and a Content Security Policy are the minimum set. If you're behind nginx, add them there — they apply to every response uniformly.
JWT vs Sessions
Stateless JWTs work well for APIs consumed by external clients or mobile apps. For browser-based applications, sessions with HttpOnly cookies are harder to XSS out of than localStorage-stored JWTs.
If you use JWTs, keep expiry short (15 minutes) and implement refresh token rotation — issue a new refresh token on each use and invalidate the old one. This limits the blast radius of a stolen refresh token.
Beware of "none" algorithm attacks. Always specify the expected algorithm explicitly when validating:
Jwts.parserBuilder()
.setSigningKey(publicKey)
.setAllowedClockSkewSeconds(30)
.build()
.parseClaimsJws(token); // throws if alg header doesn't match key type
Input Validation
Validate every @RequestBody at the controller boundary. This is not optional:
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserDetails principal
) {
// Never trust client-supplied owner IDs
// Always derive ownership from the authenticated principal
return orderService.create(request, principal.getUsername());
}
Two common mistakes: validating input but not enforcing ownership (user supplies userId=42 in the request body and you use it directly), and validating at the service layer instead of the controller (meaning the validation doesn't run in tests that mock the service).
For SQL injection: use Spring Data JPA or named parameters. Never concatenate user input into query strings. If you're writing native queries, use @Query with :param placeholders.
Security is a process, not a feature. The checklist above gets you to a defensible baseline — not an impenetrable system. Threat modelling, regular dependency scanning, and security-focused code review are what keep the baseline from eroding as the codebase grows.
Build security in from the start. Bolting it on later costs three times as much and leaves gaps.