HomeSpring BootAPI Security Best Practices
Spring Boot

API Security Best Practices

Securing a REST API requires defence in depth across multiple layers. No single mechanism is sufficient — authentication, authorisation, input validation, rate limiting, security headers, and monitoring must work together. This entry maps the OWASP API Security Top 10 (2023) to Spring Boot and covers object-level and function-level authorisation, mass assignment prevention, sensitive data exposure, input validation and injection, rate limiting, security headers and CORS, SSRF protection, audit logging, and a production security checklist.

OWASP API Security Top 10 (2023) in Spring Boot

The OWASP API Security Top 10 lists the most common and most damaging API weaknesses. Most of them are authorisation and design problems, not framework bugs, so Spring Security gives you the tools but you still have to apply them on every endpoint. The overview below maps each risk to the section of this entry that addresses it. Use it as a review guide: for each endpoint, ask which of these ten risks applies and where in the code it is handled.
Java
// ── OWASP API Security Top 10 (2023) → where it is covered ────────────
//
// API1  Broken Object Level Authorization       → BOLA section
// API2  Broken Authentication                   → authentication entries
//                                                  (JWT, refresh tokens, lockout)
// API3  Broken Object Property Level Auth.      → Mass Assignment +
//                                                  Sensitive Data Exposure
// API4  Unrestricted Resource Consumption       → Rate Limiting section
// API5  Broken Function Level Authorization     → BFLA section
// API6  Unrestricted Access to Sensitive        → Rate limiting per user/flow
//       Business Flows                             (sign-up, checkout, coupons)
// API7  Server-Side Request Forgery             → SSRF section
// API8  Security Misconfiguration               → Headers/CORS section,
//                                                  error handling, checklist
// API9  Improper Inventory Management           → retire old versions,
//                                                  secure Actuator, checklist
// API10 Unsafe Consumption of APIs              → validate third-party
//                                                  responses, set timeouts

// ── Layers that must all hold ─────────────────────────────────────────
//  Client → TLS → rate limit → authentication → function-level check
//        → object-level check → validated DTO → service → repository
//  If one layer is missing, the others must not be the only defence.

Broken Object Level Authorization (BOLA)

BOLA (also called IDOR) is the most common API vulnerability — an authenticated user accesses another user's resource by changing an ID in the request. Always verify that the authenticated user owns or has permission to access the requested object. Never trust client-supplied IDs without checking ownership. Unpredictable IDs such as UUIDs make guessing harder, but they are only an extra layer: the ownership check is still required.
Java
// ── WRONG — trusts client-supplied ID without ownership check ─────────
@GetMapping("/orders/{id}")
public ResponseEntity<OrderResponse> getOrder(
        @PathVariable Long id) {
    // Any authenticated user can see any order by guessing an ID
    return ResponseEntity.ok(orderService.findById(id));
}

// ── CORRECT — verify ownership before returning ───────────────────────
@GetMapping("/orders/{id}")
public ResponseEntity<OrderResponse> getOrder(
        @PathVariable Long id,
        @AuthenticationPrincipal AppUser principal) {
    Order order = orderRepo.findById(id)
        .orElseThrow(() -> new OrderNotFoundException(id));

    // Verify ownership — admin can see any order
    if (!order.getUserId().equals(principal.getId())
            && !principal.hasRole("ADMIN")) {
        // Return 404 not 403 — do not reveal the resource exists
        throw new OrderNotFoundException(id);
    }

    return ResponseEntity.ok(OrderResponse.from(order));
}

// ── Repository-level ownership enforcement ────────────────────────────
public interface OrderRepository
        extends JpaRepository<Order, Long> {

    // Always filter by userId — impossible to bypass at the call site
    Optional<Order> findByIdAndUserId(Long id, Long userId);

    Page<Order> findByUserId(Long userId, Pageable pageable);
}

// ── Service using ownership-scoped queries ────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepo;

    public OrderResponse findByIdForUser(Long orderId,
                                          Long userId) {
        return orderRepo.findByIdAndUserId(orderId, userId)
            .map(OrderResponse::from)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        // Returns 404 whether the order doesn't exist
        // or belongs to another user — no information leak
    }
}

// ── Test it: user B must not read user A's order ──────────────────────
// mockMvc.perform(get("/orders/" + orderOfUserA.getId())
//         .with(user(userB)))
//     .andExpect(status().isNotFound());

Broken Function Level Authorization (BFLA)

BFLA means a user can call a function they should not have access to, such as an admin endpoint, simply because the URL is reachable. Deny by default: every endpoint needs authentication unless it is explicitly public, and privileged functions need a role check. Apply the rule in two places: at the URL level in the security filter chain, and with method security (@PreAuthorize) on the controller or service, so that a missing URL rule does not expose a sensitive method. Enable method security with @EnableMethodSecurity.
Java
// ── URL-level rules: deny by default ──────────────────────────────────
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/auth/**", "/actuator/health").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
        .anyRequest().authenticated());      // everything else needs login
    return http.build();
}

// ── Method-level rules: a second layer ────────────────────────────────
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig { }

@RestController
@RequestMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")            // applies to every method
public class AdminUserController {

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

// ── Owner-or-admin rule on a service method ───────────────────────────
@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
    public UserResponse findById(Long userId) { ... }
}

// ── Common BFLA mistakes ──────────────────────────────────────────────
// ✗ Hiding the admin button in the UI but not protecting the endpoint
// ✗ Protecting GET /admin/** but forgetting POST/PUT/DELETE
// ✗ Different HTTP method on the same path is not covered by the rule
// ✗ Trusting a role sent by the client (header or request body)

Mass Assignment Prevention

Mass assignment allows attackers to set fields they should not control — role, accountBalance, isAdmin — by including them in a request body that maps directly to an entity. Always use dedicated request DTOs with only the fields the client is permitted to set. Never bind @RequestBody directly to an entity. Spring Boot's Jackson configuration ignores unknown JSON properties by default; with DTOs that is safe, but you can also reject unexpected fields by setting spring.jackson.deserialization.fail-on-unknown-properties=true so that probing requests fail loudly. (@StrongPassword and @PhoneNumber below are custom validation annotations.)
Java
// ── WRONG — entity as request body exposes all fields ────────────────
@PostMapping("/users")
public ResponseEntity<User> createUser(
        @RequestBody User user) {   // attacker sets user.setRole("ADMIN")
    return ResponseEntity.ok(userRepo.save(user));
}

// ── CORRECT — dedicated DTO with only permitted fields ────────────────
public record CreateUserRequest(
    @NotBlank @Size(min = 2, max = 100) String name,
    @NotBlank @Email                    String email,
    @NotBlank @StrongPassword           String password
    // No role, no isAdmin, no accountBalance — client cannot set these
) {}

public record UpdateUserRequest(
    @Size(min = 2, max = 100) String name,
    @Size(max = 500)          String bio,
    @PhoneNumber              String phone
    // Cannot change email, password, or role through this endpoint
) {}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository  userRepo;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        // Map only what the DTO allows
        user.setName(request.name());
        user.setEmail(request.email().trim().toLowerCase(Locale.ROOT));
        user.setPassword(passwordEncoder.encode(request.password()));
        user.setRoles(Set.of("USER"));       // role always set by server
        user.setEnabled(true);               // not settable by client
        user.setEmailVerified(false);        // not settable by client
        return UserResponse.from(userRepo.save(user));
    }

    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request,
                                Long requestingUserId) {
        User user = userRepo.findById(id).orElseThrow();
        if (!user.getId().equals(requestingUserId)) {
            throw new ForbiddenException(
                "FORBIDDEN", "Cannot update another user's profile");
        }
        // Apply only allowed fields
        if (request.name()  != null) user.setName(request.name());
        if (request.bio()   != null) user.setBio(request.bio());
        if (request.phone() != null) user.setPhone(request.phone());
        return UserResponse.from(userRepo.save(user));
    }
}

Sensitive Data Exposure

Never expose internal fields, stack traces, or sensitive data in API responses. Use response DTOs that explicitly declare what is returned; this is the main defence, and annotations such as @JsonIgnore on entities are only a safety net. Configure Spring Boot's error handling to suppress stack traces and internal exception details in production, and keep secrets, tokens and personal data out of logs.
Java
// ── WRONG — entity exposes password hash, internal fields ────────────
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepo.findById(id).orElseThrow();
    // Exposes: passwordHash, internalNotes, createdBy, etc.
}

// ── CORRECT — DTO exposes only public fields ──────────────────────────
public record UserResponse(
    Long        id,
    String      name,
    String      email,
    String      avatarUrl,
    List<String> roles,
    Instant     createdAt
    // No password, no internal fields, no audit columns
) {
    public static UserResponse from(User user) {
        return new UserResponse(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getPictureUrl(),
            user.getRoles().stream().toList(),
            user.getCreatedAt()
        );
    }
}

// ── Suppress stack traces in production errors ────────────────────────
// application-prod.yml:
// server:
//   error:
//     include-stacktrace: never
//     include-exception: false
//     include-message: never   # suppress Spring's default message
//     include-binding-errors: never

// ── Custom error controller hides internal details ────────────────────
// ErrorResponse here is your own response class (not Spring's
// org.springframework.web.ErrorResponse interface).
@RestController
@RequestMapping("${server.error.path:/error}")
public class SecureErrorController implements ErrorController {

    @RequestMapping
    public ResponseEntity<ErrorResponse> handleError(
            HttpServletRequest request) {
        Integer status = (Integer) request.getAttribute(
            RequestDispatcher.ERROR_STATUS_CODE);
        int code = status != null ? status : 500;

        // Never expose the original exception message
        String message = switch (code) {
            case 400 -> "Bad request";
            case 401 -> "Authentication required";
            case 403 -> "Access denied";
            case 404 -> "Resource not found";
            default  -> "An unexpected error occurred";
        };

        return ResponseEntity.status(code)
            .body(ErrorResponse.of(code,
                HttpStatus.valueOf(code).getReasonPhrase(),
                message, request.getRequestURI()));
    }
}

// ── @JsonIgnore as a safety net on the entity ─────────────────────────
@Entity
public class User {
    @JsonIgnore  private String passwordHash;
    @JsonIgnore  private String resetToken;
    @JsonIgnore  private String internalNotes;
}

// ── Keep secrets out of logs ──────────────────────────────────────────
// ✗ log.info("Login request: {}", request);      // may contain password
// ✓ log.info("Login attempt for user {}", maskedEmail);

Input Validation and Injection Prevention

Validate all input at the API boundary. Use Bean Validation for structural checks and sanitise free-text fields that will be rendered in HTML. Prevent SQL injection by always using parameterised queries — JPA and Spring Data handle this automatically for JPQL and derived queries, but native queries and string-built SQL need care: never concatenate user input into a query string. Character allowlists like the @Pattern below are only an example; they reject legitimate non-ASCII input, so choose them to match your data. When you build LIKE patterns from user input, escape the wildcard characters % and _ (the derived Containing queries in Spring Data do this for you).
Java
// ── Bean Validation on all request DTOs ──────────────────────────────
public record SearchRequest(

    @NotBlank
    @Size(min = 2, max = 100,
          message = "Query must be between 2 and 100 characters")
    // Example allowlist: letters, digits, space, - . , '
    // (ASCII only — adapt for your data)
    @Pattern(regexp = "^[\\w\\s\\-\\.,']+$",
             message = "Query contains invalid characters")
    String query,

    @Min(0) @Max(10000) BigDecimal minPrice,
    @Min(0) @Max(10000) BigDecimal maxPrice,

    @Size(max = 10, message = "Maximum 10 tags per search")
    List<@NotBlank @Size(max = 50) String> tags
) {}

// ── SQL injection: string concatenation ───────────────────────────────
// WRONG — user input becomes part of the SQL text
public List<Product> findByNameWrong(String name) {
    return entityManager.createNativeQuery(
        "SELECT * FROM products WHERE name LIKE '%" + name + "%'",
        Product.class).getResultList();
    // name = "' OR '1'='1" returns every row (or worse)
}

// CORRECT — parameterised native query
@Query(value =
    "SELECT * FROM products WHERE name ILIKE :pattern",
    nativeQuery = true)
List<Product> findByNamePattern(
    @Param("pattern") String pattern);

// Build the pattern in the service and escape LIKE wildcards:
public List<ProductResponse> search(String query) {
    String safe = query.replace("\\", "\\\\")
                       .replace("%", "\\%")
                       .replace("_", "\\_");
    return productRepo.findByNamePattern("%" + safe + "%")
        .stream().map(ProductResponse::from).toList();
}

// Simplest option — a derived query; Spring Data escapes wildcards:
List<Product> findByNameContainingIgnoreCase(String name);

// ── HTML sanitisation for user-generated content ──────────────────────
@Component
public class HtmlSanitizer {

    // Using OWASP Java HTML Sanitizer
    private final PolicyFactory policy =
        Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    public String sanitize(String untrusted) {
        if (untrusted == null) return null;
        return policy.sanitize(untrusted);
    }
}

@Service
@RequiredArgsConstructor
public class CommentService {

    private final HtmlSanitizer sanitizer;
    private final CommentRepository commentRepo;

    @Transactional
    public CommentResponse create(CreateCommentRequest request,
                                   Long userId) {
        Comment comment = new Comment();
        // Sanitise before storing — remove script tags etc.
        comment.setContent(
            sanitizer.sanitize(request.content()));
        comment.setUserId(userId);
        return CommentResponse.from(commentRepo.save(comment));
    }
}
// Remember: a JSON API does not render HTML itself. Output-encode in
// the client as well; sanitising on input is defence in depth.

Rate Limiting and Resource Consumption

Without limits, one client can exhaust CPU, memory or your database, or brute-force a login endpoint. Apply rate limits per IP on public endpoints and per user on authenticated ones, with a much stricter limit on login and other sensitive flows (sign-up, password reset, checkout). Also cap request body size, upload size and page size so that no single request can ask for unbounded work. The filter below uses Bucket4j (token bucket) and returns 429 with a Retry-After header. The in-memory map suits a single instance; for several instances use a shared store such as Redis, and evict idle entries so the map does not grow forever. When running behind a proxy or load balancer, derive the client IP from forwarded headers only from a trusted proxy.
Java
// ── Rate limit filter (Bucket4j) ──────────────────────────────────────
@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final Map<String, Bucket> general = new ConcurrentHashMap<>();
    private final Map<String, Bucket> login   = new ConcurrentHashMap<>();

    private static Bucket bucket(int perMinute) {
        return Bucket.builder()
            .addLimit(Bandwidth.builder()
                .capacity(perMinute)
                .refillGreedy(perMinute, Duration.ofMinutes(1))
                .build())
            .build();
    }

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

        String key = request.getRemoteAddr();   // or the user id when logged in
        boolean isLogin = "/auth/login".equals(request.getRequestURI());

        Bucket bucket = isLogin
            ? login.computeIfAbsent(key, k -> bucket(5))      //   5 / minute
            : general.computeIfAbsent(key, k -> bucket(100)); // 100 / minute

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.setHeader("X-RateLimit-Remaining",
                String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(request, response);
        } else {
            long retryAfter = Math.max(1,
                TimeUnit.NANOSECONDS.toSeconds(
                    probe.getNanosToWaitForRefill()));
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader(HttpHeaders.RETRY_AFTER,
                String.valueOf(retryAfter));
        }
    }
}

// ── Limit the size of every request ───────────────────────────────────
// application.yml:
// spring:
//   servlet:
//     multipart:
//       max-file-size: 5MB
//       max-request-size: 10MB
//   data:
//     web:
//       pageable:
//         max-page-size: 100        # blocks size=1000000 data dumps
//         default-page-size: 20
// server:
//   tomcat:
//     max-swallow-size: 2MB
//     max-http-form-post-size: 1MB

// ── Add timeouts to every outbound call (API10) ───────────────────────
// A slow third-party API must not tie up your threads:
// RestClient / WebClient / HttpClient with connect + read timeouts.

Security Headers, CORS and HTTPS

Security misconfiguration is a leading cause of API breaches. Configure the security filter chain explicitly: stateless sessions for token-based APIs, CSRF disabled only because you do not use cookie-based authentication, strict security headers, HTTPS, and a CORS allowlist. Spring Security already adds several headers by default (X-Content-Type-Options, X-Frame-Options: DENY, Cache-Control). For a JSON API, a restrictive Content-Security-Policy such as default-src 'none' is a good fit. Never combine allowedOrigins("*") with credentials, and in production list exact origins. If TLS ends at a load balancer, set server.forward-headers-strategy=framework so Spring sees the original scheme and host.
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            // Stateless token API: no session, no cookie → no CSRF token needed
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .cors(Customizer.withDefaults())      // uses the CORS bean below
            .headers(h -> h
                .contentSecurityPolicy(csp -> csp.policyDirectives(
                    "default-src 'none'; frame-ancestors 'none'"))
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))
                .frameOptions(frame -> frame.deny())
                .contentTypeOptions(Customizer.withDefaults()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**", "/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated());
        return http.build();
    }

    // ── CORS: explicit allowlist, never "*" in production ─────────────
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com", "https://admin.example.com"));
        config.setAllowedMethods(
            List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(
            List.of("Authorization", "Content-Type"));
        config.setMaxAge(Duration.ofHours(1));

        UrlBasedCorsConfigurationSource source =
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

// ── Lock down Actuator (API8 / API9) ──────────────────────────────────
// management:
//   endpoints:
//     web:
//       exposure:
//         include: health,info     # not env, heapdump, beans, etc.
//   endpoint:
//     health:
//       show-details: never

// ── Behind a TLS-terminating load balancer ────────────────────────────
// server:
//   forward-headers-strategy: framework

Server-Side Request Forgery (SSRF)

SSRF happens when your server fetches a URL supplied by a client (webhooks, image import, link preview, "fetch from URL") and the attacker points it at an internal address such as the cloud metadata service (169.254.169.254) or an internal admin port. Prefer an allowlist of permitted hosts over a blocklist. Require https, resolve the host and reject loopback, link-local and private addresses, and disable automatic redirects, because a permitted URL can redirect to an internal one. DNS can also change between your check and the request (DNS rebinding), so for high-risk features connect to the resolved address you checked, or route the requests through an egress proxy that enforces the same rules.
Java
@Component
public class OutboundUrlValidator {

    private static final Set<String> ALLOWED_HOSTS =
        Set.of("api.partner.com", "cdn.example.com");

    public URI validate(String raw) {
        URI uri;
        try {
            uri = URI.create(raw);
        } catch (IllegalArgumentException ex) {
            throw new BadRequestException("Invalid URL");
        }

        if (!"https".equalsIgnoreCase(uri.getScheme())) {
            throw new BadRequestException("Only https URLs are allowed");
        }

        String host = uri.getHost();
        if (host == null
                || !ALLOWED_HOSTS.contains(host.toLowerCase(Locale.ROOT))) {
            throw new BadRequestException("Host is not allowed");
        }

        // Defence in depth: the allowed host must not resolve to an
        // internal address.
        try {
            for (InetAddress addr : InetAddress.getAllByName(host)) {
                if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
                        || addr.isLinkLocalAddress()
                        || addr.isSiteLocalAddress()) {
                    throw new BadRequestException(
                        "Host resolves to an internal address");
                }
            }
        } catch (UnknownHostException ex) {
            throw new BadRequestException("Host cannot be resolved");
        }
        return uri;
    }
}

// ── Use it, with redirects disabled and timeouts set ──────────────────
HttpClient client = HttpClient.newBuilder()
    .followRedirects(HttpClient.Redirect.NEVER)
    .connectTimeout(Duration.ofSeconds(3))
    .build();

HttpRequest req = HttpRequest.newBuilder(validator.validate(userUrl))
    .timeout(Duration.ofSeconds(5))
    .GET()
    .build();

Audit Logging

Audit logs record who did what and when — essential for security investigations, compliance, and debugging. Log authentication events, data access, mutations, and permission denials. Use structured logging (JSON) with a correlation ID so logs can be queried and correlated across services. Three details matter in practice: write audit rows in their own transaction (REQUIRES_NEW) so that a rolled-back business transaction does not erase the record; remove line breaks from user-controlled values before logging them to prevent log injection; and keep the audit table append-only. The request-scoped HttpServletRequest injected into the aspect only works inside a web request, so read it defensively for asynchronous or scheduled calls.
Java
// ── Audit event types ─────────────────────────────────────────────────
public enum AuditAction {
    LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT,
    PASSWORD_CHANGED, ACCOUNT_LOCKED, ACCOUNT_UNLOCKED,
    RESOURCE_CREATED, RESOURCE_UPDATED, RESOURCE_DELETED,
    RESOURCE_ACCESSED, ACCESS_DENIED,
    TOKEN_ISSUED, TOKEN_REVOKED, TOKEN_THEFT_DETECTED
}

// ── Audit service ─────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {

    private final AuditLogRepository auditRepo;

    // Own transaction: the audit row survives a rollback of the caller
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(AuditAction action, String subject,
                    String resourceType, String resourceId,
                    HttpServletRequest request) {
        AuditLog entry = new AuditLog();
        entry.setAction(action.name());
        entry.setSubject(subject);
        entry.setResourceType(resourceType);
        entry.setResourceId(resourceId);
        entry.setIpAddress(request.getRemoteAddr());
        entry.setUserAgent(
            request.getHeader(HttpHeaders.USER_AGENT));
        entry.setCorrelationId(
            MDC.get("correlationId"));
        entry.setTimestamp(Instant.now());
        auditRepo.save(entry);

        // Structured log for SIEM ingestion (strip CR/LF: log injection)
        log.info("AUDIT action={} subject={} resource={}/{} ip={}",
            action, clean(subject), resourceType, clean(resourceId),
            entry.getIpAddress());
    }

    private static String clean(String value) {
        return value == null ? null : value.replaceAll("[\\r\\n]", "_");
    }
}

// ── AOP-based audit logging ───────────────────────────────────────────
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    AuditAction action();
    String      resourceType() default "";
}

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class AuditAspect {

    private final AuditService          auditService;
    private final HttpServletRequest    request;   // request-scoped proxy

    @AfterReturning(
        pointcut = "@annotation(audited)",
        returning = "result"
    )
    public void auditSuccess(JoinPoint jp,
                              Audited audited, Object result) {
        String subject = getCurrentUserEmail();
        String resourceId = extractId(result);
        auditService.log(audited.action(), subject,
            audited.resourceType(), resourceId, request);
    }

    @AfterThrowing(
        pointcut = "@annotation(audited)",
        throwing  = "ex"
    )
    public void auditFailure(JoinPoint jp,
                              Audited audited, Exception ex) {
        log.warn("AUDIT_FAILURE action={} subject={} error={}",
            audited.action(), getCurrentUserEmail(),
            ex.getClass().getSimpleName());
    }

    private String getCurrentUserEmail() {
        return Optional.ofNullable(SecurityContextHolder
                .getContext().getAuthentication())
            .map(Authentication::getName)
            .orElse("anonymous");
    }

    // Example: read an "id" from the returned DTO, or null for void
    private String extractId(Object result) {
        if (result instanceof OrderResponse r) return String.valueOf(r.id());
        return null;
    }
}

// ── Use on service methods ────────────────────────────────────────────
@Service
public class OrderService {

    @Audited(action = AuditAction.RESOURCE_CREATED,
             resourceType = "ORDER")
    public OrderResponse create(CreateOrderRequest req) { ... }

    @Audited(action = AuditAction.RESOURCE_DELETED,
             resourceType = "ORDER")
    public void delete(Long id) { ... }
}

Security Checklist

A concise security checklist covering authentication, authorisation, data protection, transport, logging, and operational security. Review each item before deploying a new API to production, and again whenever you add an endpoint.
Java
// ── Authentication ────────────────────────────────────────────────────
// ✓ Passwords hashed with BCrypt strength >= 12 or Argon2
// ✓ Access token TTL <= 15 minutes
// ✓ Refresh tokens stored hashed (SHA-256), rotated on use
// ✓ Refresh token family-based theft detection implemented
// ✓ Account lockout after N failed login attempts
// ✓ Rate limiting on /auth/login (e.g. 5 req/min per IP)
// ✓ JWT signed with RS256/ES256 (or HS256 with a >= 256-bit secret)
// ✓ JWT secret / private key in a secrets manager, not application.yml
// ✓ JWT validated: signature, expiry, issuer, audience
// ✓ JTI claim in JWT for blocklisting on revocation

// ── Authorisation ─────────────────────────────────────────────────────
// ✓ Every endpoint requires authentication or is explicitly .permitAll()
// ✓ Default rule is anyRequest().authenticated() (deny by default)
// ✓ Object-level ownership checked before returning any resource (BOLA)
// ✓ Admin endpoints use hasRole('ADMIN') at URL and method level (BFLA)
// ✓ @PreAuthorize on all sensitive service methods
// ✓ Response DTOs never expose entity internals or other users' data

// ── Input / Output ────────────────────────────────────────────────────
// ✓ @Valid on every @RequestBody parameter
// ✓ Path variables validated with @Validated + constraints
// ✓ Request DTOs used (never entity as @RequestBody)
// ✓ No stack traces in production error responses
// ✓ Error messages are generic — no internal paths or class names
// ✓ User-generated HTML sanitised before storage
// ✓ Native queries use parameterised values only
// ✓ User-supplied URLs validated against an allowlist (SSRF)

// ── Transport ─────────────────────────────────────────────────────────
// ✓ HTTPS enforced — HTTP redirects to HTTPS
// ✓ HSTS header set (max-age=31536000; includeSubDomains)
// ✓ TLS 1.2+ only — TLS 1.0 and 1.1 disabled
// ✓ CORS allowlist — never allowedOrigins("*") in production
// ✓ Content-Security-Policy header configured
// ✓ X-Content-Type-Options: nosniff
// ✓ X-Frame-Options: DENY

// ── Rate Limiting and DoS Protection ─────────────────────────────────
// ✓ Rate limiting per IP on all public endpoints
// ✓ Rate limiting per user on authenticated endpoints
// ✓ Stricter limits on sensitive flows (sign-up, reset, checkout)
// ✓ Request body size limit configured (e.g. 1 MB default)
// ✓ Pagination max page size enforced (prevent data dumps)
// ✓ Expensive query endpoints protected with stricter limits
// ✓ Timeouts on all outbound HTTP calls

// ── Logging and Monitoring ────────────────────────────────────────────
// ✓ All authentication events logged (success and failure)
// ✓ All authorisation denials logged
// ✓ Sensitive data (passwords, tokens, PII) never logged
// ✓ CR/LF stripped from user-controlled values in logs
// ✓ Correlation IDs on all log lines
// ✓ Alerts on: repeated login failures, token theft, 500 spikes
// ✓ Audit log immutable — append-only, separate from app logs

// ── Inventory, Dependencies and Operations ────────────────────────────
// ✓ Old API versions retired; no undocumented or forgotten endpoints
// ✓ Dependencies scanned for CVEs (OWASP Dependency-Check / Snyk)
// ✓ Docker image built from distroless / minimal base
// ✓ Actuator exposes only health/info; sensitive endpoints secured
// ✓ Database credentials rotated, not hard-coded
// ✓ Secrets in environment variables or secrets manager
// ✓ ddl-auto=validate in production (not update or create);
//     schema changes through Flyway or Liquibase migrations