Spring Boot API Security Hardening — Headers, Input Validation, and the Vulnerabilities That Slip Through

by Eric Hanson, Backend Developer at Clean Systems Consulting

Security headers — the baseline every API needs

HTTP security headers instruct browsers and clients how to handle responses. Missing headers allow attacks that the server can't directly prevent. Spring Security sets several by default; others require explicit configuration.

@Configuration
public class SecurityHeaderConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .headers(headers -> headers
                // Tell browsers not to sniff content type
                .contentTypeOptions(Customizer.withDefaults())
                // Prevent embedding in iframes (clickjacking)
                .frameOptions(frame -> frame.deny())
                // HSTS — force HTTPS for 1 year, include subdomains
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))
                // CSP for APIs — no scripts, no resources, just API responses
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'none'; frame-ancestors 'none'"))
                // Disable caching for API responses containing sensitive data
                .cacheControl(Customizer.withDefaults())
                // Referrer policy — don't leak URL in Referer header
                .referrerPolicy(referrer ->
                    referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.NO_REFERRER))
            )
            .build();
    }
}

Add headers Spring Security doesn't set automatically:

@Component
public class SecurityResponseHeaderFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        // Prevent browsers from caching sensitive API responses
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Pragma", "no-cache");

        // Remove server identification headers
        response.setHeader("X-Content-Type-Options", "nosniff");

        // Permissions policy — disable powerful browser features
        response.setHeader("Permissions-Policy",
            "geolocation=(), camera=(), microphone=()");

        chain.doFilter(request, response);
    }
}

Remove the Server header — it reveals the web server type and version, aiding fingerprinting:

server:
  servlet:
    application-display-name: ""

For Tomcat specifically in application.yml:

server:
  server-header: ""

Or via TomcatServletWebServerFactory:

@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
    return factory -> factory.addConnectorCustomizers(connector ->
        connector.setProperty("server", ""));
}

Mass assignment — the @RequestBody trap

Mass assignment occurs when a request body is mapped directly to a domain object or entity, allowing clients to set fields they shouldn't. The canonical example:

// DANGEROUS — client controls every field including isAdmin, accountBalance
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
    user.setId(id);
    return userRepository.save(user);
}

A malicious client sends {"email": "hacker@example.com", "isAdmin": true, "accountBalance": 1000000} and elevates their privileges.

The fix: use dedicated request objects that expose only the fields clients are allowed to set:

// Explicit — only email and displayName are writable by users
public record UpdateUserRequest(
    @Email @NotBlank String email,
    @Size(max = 100) String displayName
) {}

@PutMapping("/users/{id}")
@PreAuthorize("#id == authentication.principal.id")
public UserResponse updateUser(@PathVariable Long id,
        @RequestBody @Valid UpdateUserRequest request,
        @AuthenticationPrincipal AppUserDetails currentUser) {

    User user = userRepository.findById(id).orElseThrow();
    user.setEmail(request.email());
    user.setDisplayName(request.displayName());
    // isAdmin, accountBalance cannot be set — not in the request object
    return UserResponse.from(userRepository.save(user));
}

Never pass @RequestBody Entity directly to a repository. The request object defines the API contract; the entity is an internal representation.

The @JsonIgnoreProperties(ignoreUnknown = true) trap. This annotation tells Jackson to silently discard unknown fields. It's the correct setting for consuming external APIs where you don't control the payload. For receiving client input, consider whether unexpected fields should be silently ignored or flagged. Unknown fields in a user-controlled payload may be:

  • Harmless unknown fields (the client is using a newer API version)
  • Probe attempts to find mass assignment vulnerabilities
  • Future mass assignment vulnerabilities when new fields are added to the entity

For APIs receiving external input, log unknown fields in non-production environments to catch future vulnerabilities early.

Excessive data exposure — returning too much

A serializer that returns the full entity exposes internal fields clients shouldn't see:

// Dangerous — returns passwordHash, internalNotes, creditCardLast4, etc.
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

The User entity may have fields that are confidential (password hash, internal flags, audit fields) that don't belong in the API response. Adding a new field to the entity silently exposes it via the API.

Fix: explicit response objects that declare exactly what's exposed:

public record UserResponse(
    Long id,
    String email,
    String displayName,
    Instant createdAt
    // passwordHash, isAdmin, internalNotes NOT included
) {
    public static UserResponse from(User user) {
        return new UserResponse(user.getId(), user.getEmail(),
            user.getDisplayName(), user.getCreatedAt());
    }
}

Adding a field to User doesn't automatically expose it in the API — it must be explicitly added to UserResponse. This is the defense against accidental data exposure when the entity model grows.

SQL injection — not just for JDBC

JPA's JPQL and Spring Data derived queries are parameterized and safe. SQL injection occurs when string concatenation is used in queries:

// VULNERABLE — user input concatenated into JPQL
@Query("SELECT u FROM User u WHERE u.email = '" + email + "'")
// This cannot be written as a static @Query — it's dynamic concatenation

// In a repository implementation:
public List<User> searchUsers(String searchTerm) {
    String query = "SELECT * FROM users WHERE name LIKE '%" + searchTerm + "%'"; // VULNERABLE
    return jdbcTemplate.queryForList(query, User.class);
}

Always use parameterized queries:

// Safe — parameterized JPQL
@Query("SELECT u FROM User u WHERE u.name LIKE %:searchTerm%")
List<User> searchByName(@Param("searchTerm") String searchTerm);

// Safe — parameterized native SQL
@Query(value = "SELECT * FROM users WHERE name ILIKE :pattern", nativeQuery = true)
List<User> searchByNameNative(@Param("pattern") String pattern);

// Safe — JdbcTemplate parameterized
public List<User> searchUsers(String searchTerm) {
    return jdbcTemplate.query(
        "SELECT * FROM users WHERE name LIKE ?",
        new BeanPropertyRowMapper<>(User.class),
        "%" + searchTerm + "%"  // parameter, not concatenation
    );
}

The % wildcards for LIKE patterns are safe to add around the parameter — only the concatenation of user input into the query string is dangerous.

LIKE pattern injection. Even with parameterized queries, % and _ in user input have special meaning in LIKE patterns. searchTerm = "%" matches everything; searchTerm = "a%b%c%" can cause catastrophic backtracking on large tables:

// Escape LIKE special characters in user input
public String escapeLikePattern(String input) {
    return input.replace("\\", "\\\\")
                .replace("%", "\\%")
                .replace("_", "\\_");
}

@Query(value = "SELECT * FROM users WHERE name ILIKE :pattern ESCAPE '\\'",
       nativeQuery = true)
List<User> searchSafely(@Param("pattern") String pattern);

// Usage
String safePattern = "%" + escapeLikePattern(userInput) + "%";

Input validation beyond @NotNull

Bean Validation catches obvious cases. Several validation gaps require explicit handling:

Path variable injection. Path variables are not automatically sanitized. A UUID path variable that accepts ../../etc/passwd as a string doesn't cause path traversal in Spring MVC, but Long and UUID types reject invalid input at the framework level:

// Enforces type — non-Long values return 400 automatically
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) { ... }

// UUID path variable — invalid UUID returns 400
@GetMapping("/resources/{id}")
public Resource getResource(@PathVariable UUID id) { ... }

Use typed path variables. String path variables should be validated explicitly with @Pattern if the format is constrained.

Size limits on request bodies. Spring Boot's default max request body size is 2MB for multipart and unlimited for JSON. An attacker can send a 1GB JSON body to exhaust memory:

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 11MB

server:
  tomcat:
    max-swallow-size: 2MB    # max request body size for non-multipart
    max-http-form-post-size: 2MB

For API endpoints receiving JSON, configure a global limit:

@Bean
public FilterRegistrationBean<RequestSizeLimitFilter> sizeLimitFilter() {
    FilterRegistrationBean<RequestSizeLimitFilter> bean = new FilterRegistrationBean<>();
    bean.setFilter(new RequestSizeLimitFilter(10 * 1024 * 1024L)); // 10MB
    bean.addUrlPatterns("/api/*");
    bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return bean;
}

String length validation. A @NotBlank string field without a @Size limit accepts a 100MB string. Every string field that persists to the database should have a @Size(max = N) constraint matching the column definition:

public record CreateOrderRequest(
    @NotBlank @Size(max = 255) String referenceNumber,
    @NotBlank @Size(max = 2000) String notes,
    @NotEmpty @Size(max = 100) List<@Valid LineItemRequest> items
) {}

Sensitive data in logs and error responses

Two common data leakage channels:

Logging sensitive input. Request parameters, headers, and bodies logged at DEBUG level expose credentials, tokens, and PII in log aggregators:

// Dangerous — logs the full request body including passwords and tokens
log.debug("Received request: {}", requestBody);

// Safe — log only non-sensitive fields
log.debug("Received order request: referenceNumber={}, itemCount={}",
    request.referenceNumber(), request.items().size());

Configure Spring Security to not log credential details:

logging:
  level:
    org.springframework.security: WARN  # not DEBUG — avoids credential logging

Stack traces in error responses. Spring Boot's default error handler returns stack traces in non-production environments:

server:
  error:
    include-stacktrace: never   # never expose stack traces to clients
    include-message: always     # include the message but not the trace
    include-binding-errors: always

Stack traces reveal internal class names, library versions, and code structure — all useful for attackers targeting specific vulnerabilities.

Secrets in configuration and environment

Never log environment variables at startup. Spring Boot's EnvironmentPostProcessor and actuator /env endpoint can expose secrets:

management:
  endpoint:
    env:
      enabled: false  # disable entirely, or restrict access
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
        # env NOT included

Secrets in Spring Boot actuator properties sanitization:

@Bean
public SanitizingFunction secretSanitizer() {
    return data -> {
        String key = data.getKey().toLowerCase();
        if (key.contains("secret") || key.contains("password") ||
            key.contains("token") || key.contains("key") ||
            key.contains("credential") || key.contains("apikey")) {
            return data.withValue("**REDACTED**");
        }
        return data;
    };
}

The security headers verification checklist

Before production, verify with a tool like securityheaders.com or curl:

curl -I https://api.example.com/api/v1/health

# Should see:
# Strict-Transport-Security: max-age=31536000; includeSubDomains
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
# Cache-Control: no-store
# Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

# Should NOT see:
# Server: Apache/2.4.51 (Ubuntu)  -- reveals server info
# X-Powered-By: Spring Boot       -- reveals framework

Strip Server and X-Powered-By headers. Verify Strict-Transport-Security is present (confirms HTTPS is enforced). Verify Cache-Control: no-store on API responses containing user data.

Authentication handles identity. Authorization handles access control. The vulnerabilities above — mass assignment, excessive exposure, injection, missing headers, oversized input, leaked secrets — are the gaps that authentication and authorization don't address and that penetration testers reliably find in production APIs.

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

How Too Many Meetings Destroy Developer Productivity

Ever glance at your calendar and realize half the day is gone before you’ve written a single line of code? Too many meetings don’t just steal time—they steal focus.

Read more

Event-Driven Design in Spring Boot — ApplicationEvents, Spring Integration, and When to Use a Message Broker

Events decouple producers from consumers within and across services. Spring Boot offers three tiers: in-process ApplicationEvents for same-JVM decoupling, Spring Integration for lightweight messaging patterns, and external brokers for durability and cross-service communication.

Read more

Stop Skipping Integration Tests in Spring Boot

Unit tests give you confidence your classes work in isolation. Integration tests tell you whether your application actually works. Most Spring Boot projects have too few of the latter — and pay for it in production.

Read more

Ruby Modules and Mixins — Composition Over Inheritance in Practice

Inheritance hierarchies in Ruby tend to collapse under their own weight. Modules give you a way out, but only if you understand method lookup, hook methods, and where the pattern breaks down.

Read more