The problem: a valid user reads someone else's invoice

An API exposes GET /invoices/{id}. It requires a valid login, and the security configuration says anyRequest().authenticated(). A customer notices that changing the number in the URL returns invoices that belong to other customers. Authentication worked perfectly: the server knew who the caller was. What was missing was the question "may this caller see this object?"

This kind of flaw is common because it does not look like a bug in any single line. The OWASP API Security Top 10 is a public list of the most frequent API risk categories, and it is a good checklist for reviews. This article covers the categories that backend engineers meet most often and the code-level habits that prevent them.

Broken object level authorization

The list names it broken object level authorization: an endpoint takes an identifier from the caller and returns or changes the object without checking that the caller is allowed to touch that specific object.

// Vulnerable: any authenticated user can read any invoice@GetMapping("/invoices/{id}")InvoiceResponse get(@PathVariable Long id) {    return invoiceService.get(id);}

Fix by making the authorisation decision part of the data access, using the identity from the security context, never from a request parameter:

@GetMapping("/invoices/{id}")InvoiceResponse get(@PathVariable Long id, @AuthenticationPrincipal UserPrincipal user) {    return invoiceService.getForCustomer(id, user.customerId());}// service/repositoryOptional<Invoice> findByIdAndCustomerId(Long id, Long customerId);

If nothing matches, return 404 (or 403 where you prefer to be explicit), and use the same response for "does not exist" and "not yours" if the existence itself is sensitive. Spring Security's @PreAuthorize with @PostAuthorize or a custom permission evaluator can express the rule declaratively; whichever you choose, the check must be applied on every endpoint that takes an object identifier, including updates and deletes.

Opaque or random identifiers (UUIDs) make guessing harder, but they are not authorisation. They are a second layer, not a replacement.

Function level and property level problems

Two relatives are worth knowing by name: broken function level authorization, where a regular user can call an administrative endpoint because only the route was hidden in the UI; and broken object property level authorization, which includes excessive data exposure and mass assignment.

Mass assignment occurs when a request body is bound directly to an entity or a wide DTO, so the client can set fields it should never control:

// Vulnerable: client sends {"name":"Ana","role":"ADMIN"}@PutMapping("/me")User update(@RequestBody User user) { return users.save(user); }

Fix with a dedicated input type that contains only the fields a user may change, and map them explicitly:

public record UpdateProfileRequest(@NotBlank @Size(max = 100) String name,                                   @Size(max = 500) String bio) {}@PutMapping("/me")ProfileResponse update(@Valid @RequestBody UpdateProfileRequest request,                       @AuthenticationPrincipal UserPrincipal user) {    return profileService.update(user.id(), request);}

The same discipline applies to output: return response DTOs, not entities, so that internal fields never leak.

Function-level checks belong on the server: protect admin routes with hasRole("ADMIN") or method security, and test that an ordinary user receives 403.

Rate limiting and resource consumption

The list calls this unrestricted resource consumption. Without limits, one client can exhaust CPU, memory, database connections or a paid third-party quota, deliberately or by accident. Controls:

  • Rate limits per client or API key at the gateway or in the application, stricter on login, password reset and other costly or sensitive endpoints.
  • Maximum request body size and maximum page size (size capped on the server, for example at a few hundred).
  • Timeouts on every outbound call and on long queries.
  • Bounded concurrency for expensive operations.
  • A response such as 429 with a Retry-After header, so well-behaved clients can back off.

A related category, unrestricted access to sensitive business flows, covers abuse of legitimate functions at scale, such as mass account creation or bulk buying of limited stock. Rate limits help, but business-aware limits (one coupon per customer) matter more.

Input validation

Treat all input as untrusted: body, query, path, headers and also data from other systems you call.

  • Validate type, length, range and format with Bean Validation, as described in the validation article.
  • Use parameterised queries or JPA parameters. Never build SQL or JPQL by concatenating strings.
  • Use allow-lists for sort fields and filter names rather than passing user text into an ORDER BY.
  • Be careful with any feature that makes your server fetch a URL supplied by the user. That is server-side request forgery: validate scheme and host against an allow-list and block internal address ranges.
private static final Set<String> SORTABLE = Set.of("createdAt", "total");Sort sort(String field) {    if (!SORTABLE.contains(field)) throw new BadRequestException("Unsupported sort field");    return Sort.by(field);}

Authentication and secrets

Broken authentication covers weak login flows, missing brute-force protection, long-lived tokens and poor token validation; see the article on JWT and sessions. For secrets:

  • Keep keys, passwords and tokens out of source control and out of images. Inject them from the environment or a secret manager.
  • Use different secrets per environment and rotate them; design the application so rotation does not require a rebuild.
  • Never return secrets in API responses or put them in URLs, where proxies and logs record them.
  • Store passwords with a slow, salted algorithm provided by Spring Security (BCryptPasswordEncoder, Argon2), never with a plain fast hash.

Logging without personal data

Logs are copied to many places and kept for a long time. Log identifiers (request id, internal user id), event names and outcomes. Do not log passwords, tokens, full card numbers, complete request bodies or unnecessary personal data. Mask what you must keep. Make sure error responses do not include stack traces or SQL, as shown in the error-handling article. Log security-relevant events (failed logins, authorisation denials, rate-limit hits) so that attacks can be noticed.

Configuration and inventory

Security misconfiguration is the long tail: debug endpoints exposed, permissive CORS (* with credentials), default credentials, missing security headers, verbose errors, unneeded HTTP methods. Expose only the actuator endpoints you need and put them on a protected port or path. Improper inventory management means forgotten old API versions and undocumented endpoints still running; keep a list of what is deployed and retire what is unused. Unsafe consumption of APIs reminds you that responses from third-party services are also input to validate.

A review checklist

  1. For each endpoint that takes an identifier: where is the ownership or permission check?
  2. For each write endpoint: which exact fields can the client set?
  3. For each response: could an internal field leak?
  4. Are admin-only functions protected on the server and covered by a test?
  5. What limits exist for rate, body size, page size and time?
  6. Is every query parameterised and every sort or filter field allow-listed?
  7. Where do secrets come from, and can they be rotated?
  8. Do logs and error bodies avoid personal data, tokens and stack traces?
  9. Is there a list of exposed endpoints and versions?
  10. Does a test try the attack: user A requests user B's object?

Common mistakes

  1. Hiding buttons in the UI and calling that authorisation.
  2. Trusting identity values (user id, role) sent by the client.
  3. Doing the ownership check in the controller for some endpoints and forgetting others. Push it into a shared service or repository method.
  4. Believing that a gateway makes the application safe. The gateway cannot know who owns invoice 42.
  5. Relying on obscurity such as unlisted URLs or hard-to-guess ids.
  6. Treating security as a final review instead of tests written alongside the feature.

Trade-offs

Strict checks cost code and sometimes performance; scoped queries (findByIdAndCustomerId) cost nothing extra and are the cheapest strong fix. Aggressive rate limits protect the system but can block legitimate bursts; start from observed usage and tune. Detailed error messages help integrators but also attackers; keep detail in codes and logs rather than in text shown to unauthenticated callers.

Where to go next