Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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

Bola.java
// 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));
}
OrderRepository.java
// 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:

OwnershipEvaluator.java
@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

MassAssignment.java
// 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.

Bfla.java
// 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

ResourceLimits.java
@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);
}
application.yml
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: 20s

Cost 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

Any endpoint that fetches a user-supplied URL can be pointed at internal addresses no external attacker could reach.
SafeUrlFetcher.java
@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

AuthorizationTest.java
@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?
Broken Object Level Authorization is accessing another user resource — you may read orders, but not this one. Broken Function Level Authorization is accessing an operation you should not have at all — a regular user calling an admin endpoint. BOLA needs a per-resource ownership check; BFLA needs a role check on the endpoint.
How do I prevent mass assignment?
Never bind a request body straight onto an entity. Use a request DTO containing only the fields a client may set. A binding annotation that hides a field still allows it if someone forgets the annotation on the next field; a DTO that does not declare it cannot be set at all.
Why is SSRF specifically an API problem?
APIs frequently accept URLs — webhook callbacks, image imports, document fetches. Each one lets a caller make your server issue a request from inside your network, reaching cloud metadata endpoints and internal services no external attacker could touch directly.

Related tutorials