Mastering the essential security layers of modern Spring Boot apps.

API & Security That Scales

Building a REST API with Spring Boot is deceptively simple. You scaffold, run and endpoints respond instantly.

But here’s the uncomfortable truth: a working API is not a secure API.

Most application-layer vulnerabilities don’t come from complex crypto failures — they come from:

  • Returning database entities directly to callers
  • Missing input validation
  • Leaking stack traces in error responses
  • Trusting URL-based authorization alone

The fix isn’t “add security later.” The Day Zero Blueprint is about closing these doors, before you write a single line of business logic.

⚡ TL;DR (Quick Recap)

  • Validate at the Boundary, Java Records as DTOs to isolate your database from the web.
  • Fail Gracefully — @ControllerAdvice to ensure no stack trace ever leaves your server.
  • Stateless Auth —OAuth2 + JWT for stateless, scalable authentication
  • RBAC Layer authorization with @PreAuthorize (not just URL rules)
  • Rate Limiting and Circuit Breakers early to ensure availability.

The DTO pattern: Validate at the Boundary

The fastest way to compromise a system is to allow the outside world to talk directly to your database models.

You risk:

  • leaking internal fields
  • mass assignment attacks
  • tight coupling between DB and API

The DTO Pattern. Use Java Records to define strict, immutable contracts.

public record CreateNewsletterRequest(
@NotBlank @Size(min = 3, max = 50) String name,
@NotBlank @Email String email
) {}
@PostMapping
public ResponseEntity<NewsletterResponse> create(
@Valid @RequestBody CreateNewsletterRequest request) {
return ResponseEntity.ok(service.create(request));
}

Why it matters:

  • Decouple: DTOs represent what the user needs to see, not what the database stores.
  • Validate: Use jakarta.validation constraints directly on the Record components.
  • Enforce: Use @Valid in your controller to ensure no junk data enters your service layer.

Global Exception Handling: The Security Mask

Default Spring errors can leak:

  • stack traces
  • class names
  • database hints

That’s valuable information — just not for your users.

By implementing a @ControllerAdvice with an @ExceptionHandler, we create a "Security Mask.

@RestControllerAdvice
public class GlobalExceptionHandler {

// Validation failures → 400, field-level detail only
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid",
(existing, replacement) -> existing
));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiErrorResponse(400, "Validation failed", fieldErrors));
}

// Authorization failures → 403, no path information
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiErrorResponse> handleAccessDenied(AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ApiErrorResponse(403, "Access denied", Map.of()));
}

// Catch-all — log internally, return nothing useful externally
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGeneral(Exception ex,
HttpServletRequest request) {
var errorId = UUID.randomUUID().toString();
log.error("ErrorId={} at {}", errorId, request.getRequestURI(), ex);

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiErrorResponse(
500,
"Internal Server Error",
Map.of("trackingId", errorId)
));
}
}

// Clean, opaque error contract
public record ApiErrorResponse(
int status,
String message,
Map<String, String> details
) {}

Rule:

  • Log everything internally
  • Expose nothing sensitive externally

Stateless Security with OAuth2 & JWT

Modern distributed APIs often prefer stateless authentication. They don’t use sessions — they validate tokens.

Configure your service as a resource server that validates tokens issued by your identity provider (Keycloak, Auth0, Okta or any OIDC-compliant IdP). Spring Boot makes this simple:

spring:
security:
oauth2:
resourceserver:
jwt:
# Option A: OIDC auto-discovery (preferred)
issuer-uri: https://auth.example.com/realms/my-realm

# Option B: explicit JWK Set URI if discovery is unavailable
# jwk-set-uri: https://auth.example.com/realms/my-realm/protocol/openid-connect/certs

Best practices:

  • Use RS256 (asymmetric keys)
  • Keep tokens short-lived
  • Validate iss, aud, exp and signature algorithm
  • Use refresh token rotation

Why it matters: no session storage, no sticky sessions, scales horizontally from day one.

Browser Security Headers

Even backend APIs interact with browsers — especially public APIs consumed by SPAs or cookie-based auth scenarios.

Your SecurityFilterChain should explicitly set:

  • HSTS: Enforce HTTPS for the next year.
  • X-Content-Type-Options: Prevent the browser from “sniffing” the MIME type.
  • CORS: Hardcode your allowed origins. Never, ever use *.
  • Content Security Policy (CSP): Restrict where the browser can load scripts from.

But pure machine-to-machine APIs often don’t need CSP, HSTS, etc.

Layer Your Authorization: RBAC and @PreAuthorize

URL pattern security rules are the first line of defense, not the last.

They break when:

  • routes change
  • internal calls bypass controllers
  • configs are misaligned

Use method-level security:

@Configuration
@EnableMethodSecurity // Crucial: Without this, @PreAuthorize is ignored.
public class SecurityConfig { ... }

// and then
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { }

Or ownership-based:

@PreAuthorize("#userId == principal.username or hasRole('ADMIN')")
public User getUser(String userId) { }

// or - depends on how your JWT is mapped (username vs sub claim)

@PreAuthorize("#userId == authentication.principal.claims['sub']")
public User getUser(String userId) { }

Why it matters:

  • Security lives with business logic
  • Survives misconfigurations
  • Enables fine-grained control

Standardized Documentation: OpenAPI

If your API requires authentication, your docs must show it.

Open API SpringDoc:

@Configuration
public class OpenApiConfig {

@Bean
public OpenAPI securedOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("My API")
.version("1.0.0"))
// 1. Declare the security scheme
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Provide a valid JWT access token")))
// 2. Apply it globally (or per-operation with @SecurityRequirement)
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
}
}

Why it matters:

  • Roadmap for consumers
  • Swagger UI becomes testable
  • Reduces integration friction

Resilience: Rate Limit Before You Need To

Security is also about availability. If an attacker can overwhelm your identity provider or spam your registration endpoint, your security is moot.

Rate Limiting throttles requests before they become a problem:

  • Bucket4j — in-process library, runs inside your application. Good for single-service setups.
  • Spring Cloud Gateway — infrastructure-level, sits in front of your services. A different architectural decision, not a drop-in alternative.

Circuit Breakers with Resilience4j protect you from cascading failures. If your OAuth2 server or an external security filter starts lagging, the circuit opens and your service fails fast — preserving resources instead of queuing thousands of waiting threads.

Pro tip:

  • Rate limit before authentication for public endpoints
  • Use stricter limits for /login and /register

Final Takeaways

API & Security is a moving target, but with these pillars, your Spring Boot application starts its life on high ground. This about establishing a Standard of Quality:

  • you reduce your attack surface dramatically
  • you avoid painful refactors later
  • you build APIs that scale — safely

Start with these. Once they are in place, the second tier (secrets management, audit logging, mTLS for service-to-service calls, dependency scanning) builds naturally on top.

You can find example of code on GitHub.

Originally posted on marconak-matej.medium.com.