Securing a Spring Boot API Beyond Authentication — OWASP Top 10 in Practice
by Eric Hanson, Backend Developer at Clean Systems Consulting
API1: Broken Object-Level Authorization (BOLA/IDOR)
The most common API vulnerability. An authenticated user accesses another user's resource by supplying a different resource ID.
// VULNERABLE
@GetMapping("/invoices/{id}")
public Invoice getInvoice(@PathVariable Long id) {
return invoiceRepository.findById(id).orElseThrow();
// No ownership check — user A can read user B's invoice
}
// SAFE — query enforces ownership
@GetMapping("/invoices/{id}")
public Invoice getInvoice(@PathVariable Long id,
@AuthenticationPrincipal AppUserDetails user) {
return invoiceRepository.findByIdAndOwnerId(id, user.getId())
.orElseThrow(() -> new InvoiceNotFoundException(id));
}
Mitigation: For every endpoint accepting a resource ID, verify the caller owns or has explicit permission to access that resource. Use repository queries that include ownership in the WHERE clause rather than loading then checking. Return 404 rather than 403 for unauthorized access — don't confirm that the resource exists.
API2: Broken Authentication
Weak JWT implementations, missing token expiry validation, predictable tokens, no token revocation.
// VULNERABLE — doesn't validate expiry or signature properly
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true; // only checks signature, not expiry
} catch (Exception e) {
return false;
}
}
// SAFE — Spring Security OAuth2 resource server handles this correctly
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwksUri).build();
// Validates: signature, expiry, not-before, issuer, audience
OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtClaimValidator<>(JwtClaimNames.AUD,
aud -> ((List<?>) aud).contains("my-service"))
);
decoder.setJwtValidator(validators);
return decoder;
}
Mitigation: Use Spring Security's OAuth2 resource server — it handles token validation correctly. Validate: signature, expiry (exp), not-before (nbf), issuer (iss), and audience (aud). Short-lived access tokens (15 minutes) with refresh tokens reduce the blast radius of token theft.
API3: Broken Object Property Level Authorization
An authenticated user can read or write fields they shouldn't have access to on resources they legitimately access.
// VULNERABLE — admin-only fields visible to regular users
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
// Returns: id, email, name, AND internalScore, fraudFlag, adminNotes
}
// VULNERABLE — user can set their own role via mass assignment
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
user.setId(id);
return userRepository.save(user); // saves whatever role the client sends
}
Mitigation: Use role-specific response objects that include only the fields each role should see:
@GetMapping("/users/{id}")
public Object getUser(@PathVariable Long id,
@AuthenticationPrincipal AppUserDetails currentUser) {
User user = userRepository.findById(id).orElseThrow();
if (currentUser.hasRole("ADMIN")) {
return AdminUserResponse.from(user); // includes internal fields
}
return PublicUserResponse.from(user); // public fields only
}
For writes, use request objects that only expose the fields a user may set — never bind request body directly to an entity.
API4: Unrestricted Resource Consumption
No rate limiting, no request size limits, expensive operations callable without throttling.
// VULNERABLE — no rate limiting, no size limit, expensive operation
@PostMapping("/search")
public List<SearchResult> search(@RequestBody SearchRequest request) {
return searchService.fullTextSearch(request.getQuery()); // unbounded
}
Mitigation: Apply rate limiting per user/IP, limit request body size, cap result set sizes, and add timeouts:
@PostMapping("/search")
@RateLimiter(name = "search-endpoint") // Resilience4j — 10 requests/minute per user
public Page<SearchResult> search(
@RequestBody @Valid SearchRequest request,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") @Max(100) int size) {
return searchService.search(request.getQuery(),
PageRequest.of(page, size)); // paginated, size-capped
}
Configure global request size limits:
spring:
servlet:
multipart:
max-request-size: 10MB
server:
tomcat:
max-swallow-size: 2MB
API5: Broken Function Level Authorization
Endpoints for privileged functions (admin operations, internal tools) accessible to regular users because the check is URL-based and the attacker guesses the path.
// VULNERABLE — admin endpoint with only URL-level protection
// If a user guesses /api/admin/users, they can access it
@GetMapping("/api/admin/users")
public List<User> getAllUsers() {
return userRepository.findAll();
}
Mitigation: Enforce function-level authorization at the method level, not just the URL:
@GetMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')") // enforced regardless of URL knowledge
public List<User> getAllUsers() {
return userRepository.findAll();
}
Additionally, separate admin APIs onto a different port or host (not accessible through the public load balancer):
management:
server:
port: 8081 # admin endpoints on separate port, internal network only
API6: Unrestricted Access to Sensitive Business Flows
Business logic flows that can be exploited — bulk purchase to exhaust inventory, mass account creation for spam, automated voting.
// VULNERABLE — no protection against automated exploitation
@PostMapping("/products/{id}/purchase")
public Purchase buyProduct(@PathVariable Long id,
@RequestBody PurchaseRequest request) {
return purchaseService.purchase(id, request.getQuantity());
// Bot can purchase all inventory in milliseconds
}
Mitigation: Combine rate limiting with business logic validation:
@PostMapping("/products/{id}/purchase")
@RateLimiter(name = "purchase") // 10 purchases per hour per user
public Purchase buyProduct(@PathVariable Long id,
@RequestBody @Valid PurchaseRequest request,
@AuthenticationPrincipal AppUserDetails user) {
// Business rule: max 5 of any single product per day per user
long todayPurchases = purchaseRepository
.countByUserIdAndProductIdAndCreatedAtAfter(
user.getId(), id, Instant.now().minus(Duration.ofDays(1)));
if (todayPurchases + request.getQuantity() > 5) {
throw new PurchaseLimitExceededException("Maximum 5 per day");
}
return purchaseService.purchase(id, request.getQuantity(), user.getId());
}
API7: Server-Side Request Forgery (SSRF)
The application fetches a URL provided by the user — an attacker provides an internal URL to probe internal services or cloud metadata endpoints.
// VULNERABLE — fetches user-controlled URL
@PostMapping("/webhooks/test")
public WebhookTestResult testWebhook(@RequestBody TestWebhookRequest request) {
// Attacker sends: url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
return webClient.post()
.uri(request.getUrl()) // user-controlled URL — SSRF
.retrieve()
.bodyToMono(WebhookTestResult.class)
.block();
}
Mitigation: Validate URLs against an allowlist before fetching:
@Component
public class UrlValidator {
private static final List<String> BLOCKED_PATTERNS = List.of(
"169.254.", "10.", "172.16.", "192.168.", "127.", "localhost", "0.0.0.0",
"metadata.google.internal", "::1"
);
public void validateWebhookUrl(String url) {
try {
URI uri = new URI(url);
if (!List.of("https", "http").contains(uri.getScheme())) {
throw new InvalidUrlException("Only http/https URLs allowed");
}
String host = uri.getHost().toLowerCase();
if (BLOCKED_PATTERNS.stream().anyMatch(host::contains)) {
throw new InvalidUrlException("URL points to restricted address");
}
// Also resolve DNS and check the resolved IP
InetAddress address = InetAddress.getByName(host);
if (address.isLoopbackAddress() || address.isSiteLocalAddress() ||
address.isLinkLocalAddress()) {
throw new InvalidUrlException("URL resolves to restricted address");
}
} catch (URISyntaxException | UnknownHostException e) {
throw new InvalidUrlException("Invalid URL: " + e.getMessage());
}
}
}
DNS rebinding requires re-resolving the hostname at request time — validate, then use the validated IP directly in the request.
API8: Security Misconfiguration
Verbose error messages, stack traces in responses, unnecessary endpoints exposed, default credentials, debug mode in production.
# VULNERABLE — exposes too much
server:
error:
include-stacktrace: always
include-message: always
management:
endpoints:
web:
exposure:
include: "*" # exposes heapdump, env, shutdown
spring:
datasource:
url: jdbc:postgresql://localhost/db
username: postgres
password: postgres # default credentials
Mitigation:
server:
error:
include-stacktrace: never
include-message: never # use custom error messages only
management:
endpoints:
web:
exposure:
include: health, metrics, prometheus
server:
port: 8081 # internal only
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
Custom error handler that never exposes internals:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex,
HttpServletRequest request) {
log.error("Unhandled exception for {}", request.getRequestURI(), ex);
// Log the real exception, return a safe message to the client
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("internal_error",
"An unexpected error occurred. Contact support if this persists."));
}
}
API9: Improper Inventory Management
Undocumented or forgotten API versions still accessible, debug or test endpoints left in production, different security posture between versions.
Mitigation: Track every exposed endpoint:
# Generate a list of all mapped endpoints at startup
curl http://localhost:8080/actuator/mappings | jq '.contexts.application.mappings.dispatcherServlets'
Keep an endpoint inventory and compare against expected endpoints in CI. Any unexpected endpoint in the inventory is a misconfiguration. Disable Swagger UI and API docs in production unless explicitly required:
springdoc:
swagger-ui:
enabled: ${SWAGGER_ENABLED:false}
api-docs:
enabled: ${API_DOCS_ENABLED:false}
API10: Unsafe Consumption of APIs
Trusting data from third-party APIs without validation — SSRF via a compromised third party, injection via unvalidated data from external APIs.
// VULNERABLE — blindly trusts data from external API
@GetMapping("/products/{id}/reviews")
public List<Review> getReviews(@PathVariable Long id) {
// If the reviews API is compromised, it could inject malicious data
return reviewApiClient.getReviews(id);
// Stored as-is, including potential XSS payloads or SQL injection content
}
Mitigation: Validate and sanitize data from all external APIs before storing or returning:
@GetMapping("/products/{id}/reviews")
public List<ReviewResponse> getReviews(@PathVariable Long id) {
List<ExternalReview> rawReviews = reviewApiClient.getReviews(id);
return rawReviews.stream()
.filter(r -> r.getRating() >= 1 && r.getRating() <= 5) // validate range
.filter(r -> r.getContent() != null && r.getContent().length() <= 5000) // size limit
.map(r -> new ReviewResponse(
r.getRating(),
sanitizeHtml(r.getContent()), // remove XSS payloads
truncate(r.getAuthor(), 100) // enforce length limits
))
.toList();
}
Use a library like OWASP Java HTML Sanitizer for HTML content from external sources. Never store unsanitized external content that will be returned to clients.
The security review checklist
For each Spring Boot API endpoint, verify:
- BOLA: Does accepting this ID require an ownership check? Is the check implemented?
- Authentication: Is token validation delegating to Spring Security's resource server? Are all required claims validated?
- Property-level: Does the response contain fields the caller shouldn't see? Does the request bind to fields the caller shouldn't set?
- Rate limiting: Is there a rate limit on this endpoint? Is it appropriate for the endpoint's cost?
- Function-level: Does
@PreAuthorizeenforce the required role at the method level? - Business flows: Can this endpoint be exploited by automation? Are there business-logic guards?
- SSRF: Does this endpoint fetch a user-provided URL? Is it validated against an allowlist?
- Error handling: Does the error response expose internal details? Are all exceptions caught and sanitized?
- Inventory: Is this endpoint documented and expected? Is it in the right API version?
- External data: Is data from external APIs validated and sanitized before use?
Authentication is necessary. The ten categories above are where authenticated APIs fail in production.