Spring Boot Security Checklist — What to Verify Before Going to Production
by Eric Hanson, Backend Developer at Clean Systems Consulting
Authentication
JWT validation is complete. Spring Security's OAuth2 resource server validates signature, expiry, issuer, and not-before by default. The audience (aud) claim requires explicit configuration — add it:
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
JwtClaimNames.AUD,
aud -> aud != null && ((List<?>) aud).contains("your-service-name")
);
Without audience validation, a valid token issued for a different service is accepted by your service — a token substitution attack vector.
Token lifetimes are short. Access tokens should expire in 15–60 minutes. Long-lived access tokens (24 hours, 7 days) increase the blast radius of token theft — the window during which a stolen token is usable. Refresh tokens can be longer-lived but should be rotatable.
Sensitive endpoints require fresh authentication. Account deletion, password change, payment method update — these should require re-authentication or a recent authentication timestamp in the token claims, not just a valid token. A token issued 23 hours ago should not be sufficient to delete an account.
Form login has CSRF protection. If the application uses form-based authentication (not stateless JWT), CSRF tokens must be enabled. Spring Security enables CSRF by default; stateless JWT APIs should disable it explicitly:
.csrf(csrf -> csrf.disable()) // only for stateless JWT APIs
Authentication failures are rate-limited. Login endpoints without throttling allow brute force attacks. Five failed attempts per IP per 15 minutes is a reasonable baseline.
Authorization
Every endpoint has an explicit authorization decision. anyRequest().authenticated() is the minimum; sensitive endpoints need hasRole() or @PreAuthorize. Verify no endpoint is unintentionally public:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated() // everything else requires auth
)
Object-level authorization is implemented for every resource endpoint. Every endpoint that accepts a resource ID (@PathVariable Long id) must verify the caller has permission to access that specific resource — not just that they're authenticated. The check must be in code, not just in documentation:
// Must be present — authentication alone is not sufficient
@PreAuthorize("@orderSecurity.isOwner(#id, authentication)")
public OrderResponse getOrder(@PathVariable Long id) { ... }
Admin endpoints are separately protected. Admin functionality requires an explicit role check at the method level. URL-pattern matching alone is insufficient — an attacker who guesses the path bypasses URL-level security. @PreAuthorize("hasRole('ADMIN')") at the method level enforces authorization regardless of how the endpoint is reached.
Bulk operations verify ownership for every ID. A bulk cancel endpoint that processes a list of order IDs must verify the caller owns each ID, not just that they're authenticated. Filter to owned IDs before processing:
List<Long> ownedIds = orderRepository.findIdsByIdsAndUserId(
request.orderIds(), currentUser.getId());
// Only process ownedIds, silently skip the rest
@PostFilter is not used on large datasets. @PostFilter("filterObject.userId == authentication.principal.id") loads all records then filters in Java — a full table scan disguised as authorization. Use a repository query with ownership in the WHERE clause instead.
Input Validation
All request body fields have size constraints. A String field without @Size(max = N) accepts a 100MB input. Every String field should have @Size(max = N) matching the database column definition:
public record CreateOrderRequest(
@NotBlank @Size(max = 255) String referenceNumber,
@NotEmpty @Size(max = 100) List<@Valid LineItemRequest> items
) {}
Request body size has a global limit. Without configuration, JSON request bodies are unbounded. Set a global limit:
server:
tomcat:
max-swallow-size: 10MB
Path variables use typed parameters. @PathVariable String id accepts ../../etc/passwd. @PathVariable Long id or @PathVariable UUID id rejects non-numeric/non-UUID values at the framework level with a 400 response. Use typed path variables.
LIKE pattern characters are escaped. Parameterized queries prevent SQL injection but % and _ in user input still have special meaning in LIKE patterns. Escape them before passing to LIKE queries.
XML input processing has external entities disabled. If the application parses XML from user input, confirm DOCTYPE processing is disabled:
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
User-supplied URLs are validated before fetching. Any feature that fetches a URL provided by a user (webhook testing, URL preview, import from URL) must validate against a blocklist of private/metadata addresses before making the request.
Data Protection
Response objects are explicit — no entity serialization. Controller methods return dedicated response objects, not JPA entities. Entities may contain fields that should not be exposed (password hashes, internal flags, audit data). A new field added to the entity should not automatically appear in the API response.
Sensitive fields are not logged. Passwords, API keys, tokens, credit card numbers, and PII should never appear in log output. Check Spring Security's log level — DEBUG logs authentication details including credentials:
logging:
level:
org.springframework.security: WARN # never DEBUG in production
Stack traces are not returned in error responses:
server:
error:
include-stacktrace: never
include-message: never
Sensitive data has Cache-Control: no-store. API responses containing user-specific data should not be cached by browsers or CDNs:
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(sensitiveResponse);
Database credentials are in environment variables, not in committed files. application.yml in the repository should not contain actual credentials. Only placeholders:
spring:
datasource:
password: ${DATABASE_PASSWORD} # from environment
HTTP Security Headers
Verify these headers are present on all API responses:
curl -I https://api.example.com/api/v1/health
Required headers:
Strict-Transport-Security: max-age=31536000; includeSubDomains— enforces HTTPSX-Content-Type-Options: nosniff— prevents MIME-type sniffingX-Frame-Options: DENY— prevents clickjackingCache-Control: no-store— prevents caching of sensitive responses
Headers that should be absent:
Server: Apache/2.4.51— reveals server type and versionX-Powered-By: Spring Boot— reveals framework
Spring Security sets X-Content-Type-Options and X-Frame-Options by default. Strict-Transport-Security requires HTTPS to be active — it's set by Spring Security when the request is HTTPS. The Server header requires explicit Tomcat configuration to remove:
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
return factory -> factory.addConnectorCustomizers(
connector -> connector.setProperty("server", ""));
}
Actuator and Monitoring
Management port is not the same as the application port. Management endpoints on a separate port (8081) are inaccessible through the public load balancer:
management:
server:
port: 8081
Only safe endpoints are exposed:
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
# NOT: env, heapdump, shutdown, loggers (unless specifically needed and secured)
/actuator/env is disabled or secured. The env endpoint exposes environment variables including secrets (even if sanitized, the existence of variable names reveals system configuration):
management:
endpoint:
env:
enabled: false
Health endpoint shows details only to authorized callers:
management:
endpoint:
health:
show-details: when-authorized
show-components: when-authorized
Custom sanitizing patterns cover application-specific secret names:
@Bean
public SanitizingFunction sanitizingFunction() {
return data -> {
String key = data.getKey().toLowerCase();
if (key.contains("apikey") || key.contains("webhook") ||
key.contains("secret") || key.contains("credential")) {
return data.withValue("**REDACTED**");
}
return data;
};
}
Rate Limiting
Authentication endpoints are rate-limited. Login, password reset, and registration endpoints without rate limiting enable brute force and account enumeration attacks.
Expensive endpoints have tighter limits. Search, export, bulk operations, and other resource-intensive endpoints should have lower rate limits than simple reads. A search endpoint with the same rate limit as a simple GET endpoint can be used to exhaust database resources.
Rate limit headers are returned on every response. Clients need to know their current rate limit state to throttle themselves:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 743
X-RateLimit-Reset: 1713360000
429 responses include Retry-After. Without it, clients implementing backoff have to guess when to retry.
Dependency Security
Dependencies are scanned in CI. The OWASP Dependency Check Maven plugin or a tool like Snyk or Dependabot alerts on known CVEs in dependencies:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>9.0.9</version>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS> <!-- fail on high severity -->
</configuration>
</plugin>
Spring Boot is on a supported version. Spring Boot releases receive security patches for approximately 18 months. Using an EOL version means known vulnerabilities won't receive fixes. Check https://spring.io/projects/spring-boot#support.
Transitive dependencies are reviewed for known vulnerabilities. ./mvnw dependency:tree shows the full dependency graph. CVEs in transitive dependencies (Log4Shell being the most famous example) are as dangerous as CVEs in direct dependencies.
Secrets Management
No secrets in version control. git log -p | grep -E "(password|secret|apikey|token)" --ignore-case reveals any secrets committed historically — including in branches and history. If found, rotate immediately and use git history rewriting tools.
Secret values are not logged at startup. Spring Boot logs active properties at startup when debug logging is enabled. Verify that secret values don't appear in startup logs.
Credentials are rotatable without restart. For production systems, credentials should be updatable without a full deployment — either via Spring Cloud Config refresh, Vault dynamic secrets, or Kubernetes Secret with rolling restart.
The pre-production security run
Run this sequence before every production deployment:
# 1. Dependency vulnerabilities
./mvnw org.owasp:dependency-check-maven:check
# 2. Security headers
curl -I https://staging.api.example.com/api/v1/health | grep -E "(Strict|Content|Frame|Cache)"
# 3. Exposed endpoints audit
curl http://internal:8081/actuator/mappings | jq '.contexts | keys'
# 4. Actuator exposure check
curl http://internal:8081/actuator | jq '.["_links"] | keys'
# 5. Error response format (no stack traces)
curl -X GET https://staging.api.example.com/api/v1/orders/99999999
# Should return JSON error, not HTML, no stack trace
# 6. IDOR spot check
# Authenticate as user A, attempt to access a resource belonging to user B
# Should return 403 or 404, never 200
Security is not a feature implemented once. It's a property verified continuously. This checklist is not exhaustive — it's the baseline below which a production API should not fall.