API Security & the OWASP API Top 10
The API-specific vulnerability classes and their Spring fixes: broken object-level authorization, mass assignment, unrestricted consumption, SSRF, and API inventory management.
On this page
The OWASP API Top 10 exists because API vulnerabilities differ from web application ones. There is no browser, no HTML, no session — instead there are object identifiers in URLs, JSON bodies bound straight onto entities, and endpoints nobody remembers deploying.
Key Takeaways
- BOLA is the top risk: authentication proves who you are, not that you own the resource.
- Mass assignment is prevented by request DTOs, not by annotations on entities.
- Every endpoint needs a resource limit — pagination caps, payload size, rate limits.
- Any endpoint accepting a URL is an SSRF risk by default.
- You cannot secure endpoints you have forgotten exist.
API1 — Broken Object Level Authorization
// VULNERABLE: authenticated, but no ownership check. Changing the id in the
// URL returns someone else's order.
@GetMapping("/api/v1/orders/{id}")
public OrderResponse get(@PathVariable String id) {
return orderService.find(id);
}
// FIXED: the query itself is scoped to the caller, so an id they do not own
// simply does not exist as far as this endpoint is concerned.
@GetMapping("/api/v1/orders/{id}")
public OrderResponse get(@PathVariable String id, @AuthenticationPrincipal Jwt jwt) {
return orderService.findForCustomer(id, jwt.getSubject())
.orElseThrow(() -> new ResourceNotFoundException("order", id));
}// Scoping in the query is safer than fetching then checking, because there is
// no branch a future refactor can accidentally remove.
@Query("select o from Order o where o.id = :id and o.customerId = :customerId")
Optional<Order> findByIdAndCustomerId(@Param("id") String id,
@Param("customerId") String customerId);Returning 404 rather than 403 is deliberate. A 403 confirms the resource exists, which is itself
information — an attacker can enumerate valid identifiers by the difference in status code.
For a domain where ownership rules are more complex, centralise the check rather than repeating it:
@Component("ownership")
public class OwnershipEvaluator {
public boolean canAccess(Authentication auth, String orderId) {
return orders.findById(orderId)
.map(o -> o.customerId().equals(auth.getName())
|| auth.getAuthorities().contains(SUPPORT_ROLE))
.orElse(false);
}
}
@PreAuthorize("@ownership.canAccess(authentication, #id)")
public OrderResponse get(@PathVariable String id) { }API3 — Mass assignment
// VULNERABLE: any field on the entity can be set from the request body,
// including ones the client should never control.
@PutMapping("/api/v1/users/{id}")
public User update(@PathVariable Long id, @RequestBody User user) {
return userRepository.save(user); // {"role":"ADMIN","creditBalance":999999}
}
// FIXED: the DTO declares exactly what a client may change. A field it does
// not contain cannot be set, no matter what the request body says.
public record UpdateProfileRequest(
@NotBlank @Size(max = 100) String displayName,
@Email String email,
@Size(max = 500) String bio) { }
@PutMapping("/api/v1/users/{id}")
public UserResponse update(@PathVariable Long id,
@Valid @RequestBody UpdateProfileRequest request,
@AuthenticationPrincipal Jwt jwt) {
if (!jwt.getSubject().equals(String.valueOf(id))) throw new AccessDeniedException("not yours");
return userService.updateProfile(id, request);
}The reason to prefer a DTO over @JsonIgnore on the entity is durability. An annotation protects the
fields somebody remembered to annotate; a DTO protects everything by omission, including the field
added next month by someone who never read this guidance.
API5 — Broken Function Level Authorization
BOLA is about reaching the wrong object; BFLA is about reaching the wrong operation. The usual
shape is an administrative endpoint protected by nothing except not being linked anywhere — there is no
/admin in the navigation, so nobody assumed a check was needed. API clients do not read navigation,
and an endpoint list is one /v3/api-docs request away.
// VULNERABLE: authenticated is the only requirement, so every ordinary user
// can call it.
@DeleteMapping("/api/v1/admin/users/{id}")
public void delete(@PathVariable Long id) { }
// FIXED: the capability is asserted on the method, so it holds however the
// caller arrived — HTTP, a message listener, or another service.
@DeleteMapping("/api/v1/admin/users/{id}")
@PreAuthorize("hasAuthority('USER_DELETE')")
public void delete(@PathVariable Long id) { }Two habits prevent most of this class. Deny by default in the HTTP rules, so that
anyRequest().denyAll() makes a newly added endpoint unreachable until somebody deliberately grants
access, rather than public until somebody notices. And pay attention to the HTTP method: a rule
matching /api/v1/orders/** with no method specified treats GET and DELETE identically, which is
almost never what the author had in mind.
The reason this stays common is that both failure modes are invisible to the tests teams actually
write. An admin calling an admin endpoint passes; nobody writes the test where an ordinary user calls
it and expects a 403.
API4 — Unrestricted resource consumption
@GetMapping("/api/v1/orders")
public Page<OrderResponse> list(
// Without a cap, ?size=1000000 is a denial of service in one request.
@PageableDefault(size = 20) @Valid Pageable pageable) {
if (pageable.getPageSize() > 100) {
throw new BadRequestException("maximum page size is 100");
}
return orderService.list(pageable);
}spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 60MB
server:
tomcat:
max-http-form-post-size: 2MB
max-swallow-size: 25MB
connection-timeout: 20sCost is not always proportional to request count, which is why a plain rate limit is insufficient. An export endpoint, a complex search, a report generation — each costs orders of magnitude more than a key lookup, and each needs its own tighter limit or a queue.
API7 — SSRF
@Component
public class SafeUrlFetcher {
private static final List<IpAddressMatcher> BLOCKED = Stream.of(
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "0.0.0.0/8", "::1/128", "fc00::/7")
.map(IpAddressMatcher::new).toList();
public byte[] fetch(String rawUrl) {
URI uri = URI.create(rawUrl);
if (!Set.of("http", "https").contains(uri.getScheme())) {
throw new BadRequestException("only http and https are permitted");
}
InetAddress resolved;
try {
resolved = InetAddress.getByName(uri.getHost());
} catch (UnknownHostException ex) {
throw new BadRequestException("host could not be resolved");
}
String ip = resolved.getHostAddress();
if (BLOCKED.stream().anyMatch(matcher -> matcher.matches(ip))) {
throw new BadRequestException("that address is not permitted");
}
// Connect to the IP we validated, not the hostname. Re-resolving here
// reopens a DNS rebinding window between check and connect.
return client.get()
.uri(uriBuilder -> uriBuilder.scheme(uri.getScheme()).host(ip)
.port(uri.getPort()).path(uri.getPath()).build())
.header(HttpHeaders.HOST, uri.getHost())
.retrieve().body(byte[].class);
}
}The connect-to-the-validated-IP step is what closes DNS rebinding: an attacker controls a domain that
resolves to a safe address when you check it and to 169.254.169.254 a moment later when you connect.
Add an egress network policy as a second layer, so even a bypass cannot reach the metadata endpoint.
API9 — Improper inventory management
The endpoints that get breached are the ones nobody remembers: an old /api/v0/ still routed, a
debug endpoint from a sprint two years ago, a staging host with production data.
Generate an inventory from the OpenAPI spec in CI and diff it against the previous release, so a new endpoint is a visible, reviewed change. Scan deployed environments for routes the spec does not declare. And enumerate every environment — an unauthenticated staging copy is a production breach when it shares the database.
Testing
@Test
void cannotReadAnotherCustomersOrder() throws Exception {
mvc.perform(get("/api/v1/orders/{id}", orderBelongingToBob)
.with(jwt().jwt(j -> j.subject("alice"))))
// 404, not 403 — a 403 confirms the order exists.
.andExpect(status().isNotFound());
}
@Test
void cannotEscalatePrivilegeViaRequestBody() throws Exception {
mvc.perform(put("/api/v1/users/{id}", aliceId)
.with(jwt().jwt(j -> j.subject(aliceId)))
.contentType(APPLICATION_JSON)
.content("""
{"displayName":"Alice","role":"ADMIN","creditBalance":999999}
"""))
.andExpect(status().isOk());
assertThat(userRepository.findById(aliceId).orElseThrow().role()).isEqualTo(Role.USER);
}Write an authorization test for every endpoint that takes a resource identifier. BOLA is the most common API vulnerability precisely because the happy-path test passes and nobody writes the other one.
What to take away
Scope every resource query to the caller and return 404 rather than 403. Bind request bodies onto
DTOs that declare only what a client may set. Cap page size, payload size and per-operation cost.
Validate and pin the IP for any URL you fetch. And keep an inventory in CI, because the endpoint you
forgot is the one that gets found.
Frequently Asked Questions
What is the difference between BOLA and BFLA?
How do I prevent mass assignment?
Why is SSRF specifically an API problem?
Related tutorials
- Transaction Management Deep DiveTransactions beyond the annotation: every propagation mode and when it applies, isolation levels and the anomalies they prevent, transaction-bound events, and why XA lost to sagas.
- gRPC in Java MicroservicesgRPC for internal service calls: Protocol Buffers and schema evolution, the four RPC types, deadlines and interceptors, Spring Boot integration, and an honest comparison with REST.
- Domain-Driven Design in PracticeDDD applied rather than described: choosing aggregate boundaries, value objects that enforce invariants, repositories, hexagonal architecture, and running an event storming session.
- Database Sharding & Scaling StrategiesScaling past one database: read replicas and routing, choosing a shard key you will not regret, hash versus range sharding, cross-shard queries, and migrating without downtime.