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 HTTPS
  • X-Content-Type-Options: nosniff — prevents MIME-type sniffing
  • X-Frame-Options: DENY — prevents clickjacking
  • Cache-Control: no-store — prevents caching of sensitive responses

Headers that should be absent:

  • Server: Apache/2.4.51 — reveals server type and version
  • X-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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Developer Who Cuts Corners to Look Fast

Speed looks impressive—until the shortcuts catch up with you. Cutting corners may make a developer look fast today, but it costs the team tomorrow.

Read more

Race Conditions and Visibility in Java — What the Memory Model Actually Guarantees

The Java Memory Model defines precisely which writes are visible to which reads, and under what conditions. Without understanding it, thread-safe code is guesswork. With it, the correct tool for each situation becomes clear.

Read more

The Strategy Pattern in Java — Replacing Conditional Dispatch With Polymorphism

Conditional dispatch — switching on a type or status to select behavior — is the most common source of rigid code in Java applications. The strategy pattern replaces the switch with polymorphism, but the right implementation depends on what varies and how often it changes.

Read more

How Singapore Scaleups Are Cutting Backend Overhead the Smart Way

You raised your Series A. You tripled your engineering team. Somehow, your backend ships slower than it did when there were four of you.

Read more