Spring 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 covers the OWASP API Security Top 10 mitigations, input validation, mass assignment prevention, sensitive data exposure, audit logging, and a security checklist.

Broken Object Level Authorization (BOLA)

BOLA (IDOR) is the most common API vulnerability — an authenticated user accesses another user's resource by manipulating an ID. Always verify that the authenticated user owns or has permission to access the requested object. Never trust client-supplied IDs without checking ownership.
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 403do 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
    }
}

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.
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().toLowerCase().trim());
        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. Configure Spring Boot's error handling to suppress stack traces and internal exception details in production.
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 ────────────────────
@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 sensitive fields from accidental serialisation ─────────
@Entity
public class User {
    @JsonIgnore  private String passwordHash;
    @JsonIgnore  private String resetToken;
    @JsonIgnore  private String internalNotes;
}

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.
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;

    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(getClientIp(request));
        entry.setUserAgent(
            request.getHeader(HttpHeaders.USER_AGENT));
        entry.setCorrelationId(
            MDC.get("correlationId"));
        entry.setTimestamp(Instant.now());
        auditRepo.save(entry);

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

// ── 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;

    @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={} error={}",
            audited.action(), ex.getMessage());
    }

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

// ── 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) { ... }
}

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 need careful handling.
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")
    // Prevent regex injection in full-text search
    @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
) {}

// ── Prevent SQL injection in native queries ───────────────────────────
// WRONG — string concatenation in a native query
@Query(value =
    "SELECT * FROM products WHERE name LIKE '%" +
    // :#{#name} concatenated inline is safe in JPQL but risky in native
    "' + :name + '%'",   // DO NOT do this
    nativeQuery = true)
List<Product> findByNameWrong(String name);

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

// Usage: pass the pattern from the service, not from the client
public List<ProductResponse> search(String query) {
    // Escape special LIKE characters
    String safe = query.replace("\", "\\")
                       .replace("%", "\%")
                       .replace("_", "\_");
    return productRepo.findByNamePattern("%" + safe + "%")
        .stream().map(ProductResponse::from).toList();
}

// ── 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));
    }
}

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.
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 HS256 (min 256-bit key) or RS256
// ✓ JWT secret stored in secrets manager, not application.yml
// ✓ JTI claim in JWT for blocklisting on revocation

// ── Authorisation ─────────────────────────────────────────────────────
// ✓ Every endpoint requires authentication or is explicitly .permitAll()
// ✓ Object-level ownership checked before returning any resource
// ✓ Admin endpoints use hasRole('ADMIN') at URL and method level
// ✓ @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

// ── Transport ─────────────────────────────────────────────────────────
// ✓ HTTPS enforced — HTTP redirects to HTTPS
// ✓ HSTS header set (max-age=31536000; includeSubDomains; preload)
// ✓ 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
// ✓ 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

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

// ── Dependencies and Operations ───────────────────────────────────────
// ✓ Dependencies scanned for CVEs (OWASP Dependency-Check / Snyk)
// ✓ Docker image built from distroless / minimal base
// ✓ Actuator sensitive endpoints secured or disabled
// ✓ Database credentials rotated, not hard-coded
// ✓ Secrets in environment variables or secrets manager
// ✓ ddl-auto=validate in production (not update or create)