July 21, 2026
How Your Spring Boot API Leaks Data (And Why Adding an Auth Check Isn’t the Fix)
BOLA/IDOR is the #1 API vulnerability on OWASP’s list. Most backend developers ship it constantly without knowing its name.

By Najee Shaheen
4 min read
OWASP ranks it as the #1 API vulnerability. Real breaches at Meta, Uber, and the USPS have been traced to it. And most backend developers have shipped it, probably in the last six months, without knowing its name.
The bug is called Broken Object Level Authorization (BOLA), or by its older name, Insecure Direct Object Reference (IDOR). Same bug, two names. Two decades on the security-industry radar, and it still ships in production code every day.
If you write Spring Boot APIs, you've almost certainly written this bug. You've almost certainly fixed it. And you may have fixed it in a way that still leaves the door slightly open.
As a backend developer for a few years working on financial and enterprise systems, I've seen this bug in nearly every codebase I've touched. Not because developers are careless, but because the fix that feels natural is subtly incomplete. This article walks through the bug, the intuitive fix, why it doesn't fully work, and what actually does.
What BOLA/IDOR Actually Is
Let's consider a Spring Boot endpoint that returns an invoice:
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceRepository invoiceRepository;
public InvoiceController(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id) {
return invoiceRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceRepository invoiceRepository;
public InvoiceController(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id) {
return invoiceRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}Nothing looks wrong here. It's a normal REST endpoint, and it requires authentication (assumed via Spring Security config), returns the requested invoice, and it 404s if the ID doesn't exist.
Now what happens if I'm logged in as User A, and I request /api/invoices/1024 (mine), then change the URL to /api/invoices/1025 and hit send?
I get User B's invoice.
The endpoint checked whether I was authenticated. It knew I was logged in, but it never checked whether I was authorized to see that specific record. That gap is BOLA.
The Intuitive Fix (And Why It's Not Quite Right)
The natural first fix is to check ownership before returning the invoice.
@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
Invoice invoice = invoiceRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!invoice.getCreatedBy().equals(currentUser.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return invoice;
}@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
Invoice invoice = invoiceRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!invoice.getCreatedBy().equals(currentUser.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return invoice;
}Fetch the invoice, check if the current user is the owner, throw 403 if not. Done, right?
Functionally, yes. This defends against the basic attack.
A malicious user who tampers with the URL can no longer read other users' invoices. If this is all you shipped, you'd pass a basic security review for this endpoint.
But there are three problems with this pattern: one subtle, one structural, and one that scales.
Problem 1: Different HTTP responses leak information
The pattern returns:
● 404 Not Found when the invoice doesn't exist
● 403 Forbidden when the invoice exists but isn't yours
● 200 OK when the invoice exists and is yours
That distinction is itself an information leak. An attacker can iterate through invoice IDs and learn which ones exist in the system without reading their contents. In a financial system, "does invoice #47829 exist" is often sensitive on its own. It tells the attacker how many invoices your business has processed, roughly when they were created (if IDs are sequential), and which IDs are worth targeting for other attacks.
Problem 2: Timing side channels
The response time when the record doesn't exist is different from when the record exists but isn't yours. The first case is a single database query that returns nothing. The second case is a database query, an object hydration, an ownership check, and then an exception throw.
That timing difference is measurable. A patient attacker can enumerate which IDs exist by response time alone, even if you fixed problem 1.
Problem 3: Human error at scale (this is the real one)
Look at the previous controller. Now imagine you don't have one endpoint, you have thirty. getInvoice, updateInvoice, deleteInvoice, getInvoiceLines, downloadInvoicePdf, attachDocument, sendInvoice, and so on. Every one of them needs the exact same three-line ownership check.
Miss it on one endpoint, and that endpoint is vulnerable. Miss it on a bulk endpoint like getAllInvoicesForProject(projectId), and you've just exposed thousands of records instead of one.
Now scale that across your entire application, across every resource type (invoices, orders, users, tickets, documents, comments), across every controller method, across every developer who has ever committed to your codebase. The probability that someone, somewhere, forgets the ownership check is so high.
Security that depends on discipline is security that fails eventually. The interesting question isn't "did the developer remember?" It's "is it possible for a developer to forget?"
The Better Approach: Push Authorization Into the Query
Here's the same endpoint, restructured:
@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
return invoiceRepository.findByIdAndCreatedBy(id, currentUser.getId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}@GetMapping("/{id}")
public Invoice getInvoice(@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal currentUser) {
return invoiceRepository.findByIdAndCreatedBy(id, currentUser.getId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}And the repository:
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
Optional<Invoice> findByIdAndCreatedBy(Long id, Long createdBy);
}public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
Optional<Invoice> findByIdAndCreatedBy(Long id, Long createdBy);
}The ownership check has been moved into the query itself. The database only returns the row if it matches both the invoice ID and the current user. If the invoice doesn't exist, or if it belongs to someone else, the result is the same: Optional.empty(), which becomes a 404.
Notice what this fixes:
● The response is identical for "doesn't exist" and "not yours." No status code leak.
● The timing is identical. One query, same code path, same response shape. No timing side channel.
● The developer can't forget it. The unsafe pattern (findById(id)) can't accidentally be called, because the repository method that exists is the safe one.
The bug isn't just fixed. It's structurally difficult to reintroduce.
The Principle That Matters More Than the Fix
The two versions above look almost identical. Three lines of code, one method rename. But the shift between them is a fundamental change in how you think about authorization.
The first version treats authorization as a check, something you remember to do after fetching data. The second treats authorization as a filter, something baked into how data is fetched in general.
This isn't unique to BOLA. The same pattern separates safe from unsafe code across almost every security topic: the goal isn't to remember to sanitize, or escape, or validate, or check — it's to design the system so the unsafe code path is hard to write in the first place. That principle, sometimes called secure by construction, is what separates architectures that stay secure over time from architectures that stay secure only until someone new joins the team.
Final Note
Two caveats worth mentioning.
First, the query-level approach only works cleanly when ownership is a simple relationship: the current user owns the resource directly. Real applications often have more complex authorization models: shared documents, project-based access, role-based visibility, hierarchical ownership. In those cases, findByIdAndCreatedBy isn't enough. You need to check membership, roles, or delegated access. But the principle is the same: push the authorization into the query, don't add it as a post-fetch check.
Second, this pattern doesn't replace defense in depth. Even with query-level filtering, your application should still enforce authentication at the API gateway, validate JWT claims, log unauthorized access attempts, and rate-limit suspicious enumeration. The fix in this article closes the most common vector for the bug. It doesn't eliminate the need to think about authorization at every layer.
The Takeaway
Next time you write a Spring Boot endpoint that returns a resource by ID, ask yourself: is the ownership check a step my code performs, or is it a property of how my code accesses data? The first version works. The second version can't fail. Most backend developers ship the first. Now you know why to ship the second.