An invoicing application exposes an API: web addresses, or URLs, that other programs call to read or change invoices.
Bob is logged in with his account. He asks for his invoice at the URL GET /invoices/7f3b,
where GET means "read". The API answers with his invoice and the response code 200, which means "OK".
Bob changes one character in the URL and asks for GET /invoices/7f3a.
The API answers 200 again. This time, the invoice is Alice's.
This vulnerability is called BOLA, for Broken Object Level Authorization: authorization is broken at the level of each object, here each invoice. OWASP, a foundation that ranks web security flaws, puts it at the top of its 2023 ranking of API vulnerabilities.
The fix is one line: the condition that checks the invoice belongs to the logged-in user. A single line is also very easy to forget.
To check that it is there, you write a test: a small program that replays a scene on its own. It shows green if all is well, red otherwise. A book on API security, Defending APIs by Colin Domoney (Packt, 2024), describes such a test without ever writing it. Here are the fix and the test, in Go and then in Symfony.
BOLA (or IDOR): a vulnerability that fits in a URL
The mechanism never changes. The URL carries the invoice's identifier, here 7f3a.
The server reads that identifier and fetches the invoice from the database, where the application
stores its invoices. It never asks who the invoice belongs to.
The confusion comes from two similar words:
- authentication says who you are. With every request, Bob sends a token, a kind of badge received at login. So the API knows it is Bob;
- authorization says what you are allowed to touch. That is what is missing here.
An API that checks the first and forgets the second lets Bob read any invoice whose identifier he knows. You will also see this same flaw called IDOR.
The usual reflex is to make identifiers impossible to guess, with long identifiers drawn at random. OWASP recommends it, on top of a real check. Colin Domoney's book calls this kind of camouflage insufficient:
"None of these are sufficient to prevent BOLA and will merely serve to delay an attacker."
None of these measures is enough; they only slow the attacker down. An identifier can end up elsewhere: in a shared link, in a screenshot. The day Bob knows it, only the ownership check stops him from reading the invoice.
Why a single-account scanner misses it: the A-B test
A vulnerability scanner is a tool that floods an API with requests and flags suspicious answers.
A scanner that uses a single account sends GET /invoices/7f3a and gets 200. The scanner is delighted:
it has no idea the invoice is Alice's. To see the flaw, you need two accounts and a memory of
who created what.
Defending APIs says so plainly:
"Unfortunately, most current test frameworks are not designed to perform the multistage A-B testing required to identify a broken object-level vulnerability."
Most testing tools are not designed to chain the requests of two different users. Yet that chain is what exposes the flaw. The book calls it the A-B test:
"First, create a resource as user A. Then, attempt to access the resource as user B. If you can, then you have an authorization issue."
User A, here Alice, creates an invoice. User B, here Bob, tries to read it. If he succeeds, authorization is broken. The A-B-A variant goes further: B tries to delete the invoice, then A asks for it again to check it is intact.
In this test, Bob must get 404, the response code for "not found". Bob must not even know the invoice exists. A "forbidden" would tell him he hit a real one.
Between the creation and Bob's request, the book has you check that A can reach their invoice. That check looks useless; it is not. The test only looks at the answer Bob gets. A broken API that answers 404 to everyone would give Bob a 404 too. The test would be green for the wrong reason. So Alice asks for her invoice again and must get 200.
The diagram below walks through these steps. It uses two more words: POST means "create", DELETE means "delete".
The 201 in step 1 is the response code for "created". The book advises writing these tests yourself. It gives no code example: the Go and Symfony sections below fill that gap.
The book's example guards deletion, not the invoice
The book's own authorization example shows the most common mistake. The author hands decisions to a rules engine, Casbin: a tool that reads rules written in a file and answers "yes" or "no".
The book's example handles "items", which play the part of our invoices here. Its rules say alice may delete items and bob may only read them.
The rule covers /items/*: it answers for all items at once.
So the engine answers the question "may this user delete invoices?".
It never asks the real BOLA question: "may this user delete this particular invoice?".
The line that deletes shows it:
items_dao.delete_item(item_id)
This line only receives the item's identifier, never its owner. So nothing checks who it belongs to.
As I read it, as soon as two users both have the right to delete, each can erase the other's invoices. The A-B-A test would show it on the first try.
When the engine refuses, the example answers with the response code 401. It means "I don't know who you are, log in". But the server knows exactly who bob is. The book's 401 is simply wrong: it sends you looking for a login problem, when what is missing is the right.
404 rather than 403
The response code 403 means "I know who you are, and the answer is no". For someone else's invoice, 403 and 404 each have an argument:
- 404 hides the invoice's existence. Bob cannot tell "it does not exist" from "it belongs to someone else". So he cannot try identifiers one by one to list the ones that exist;
- 403 helps a developer hunting for the cause of a bug: they see right away that authorization said no.
My recommendation: 404 for any data that belongs to a user, such as an invoice or an account. The developer can find the cause of the refusal in the server's logs, the files where it records every request. A list of valid identifiers, on the other hand, cannot be taken back once Bob has built it. So both tests, Go and Symfony, require 404.
RFC 9110, the document that defines HTTP, allows for this: a server that wants to hide the existence of a forbidden resource may answer 404. One limit: if Bob may read the invoice but not delete it, he already knows it exists. Refusing the delete is then a 403.
In Go: filter by owner, then prove it
The fix: the owner goes in the lookup
The principle: never fetch an invoice by its identifier alone. The lookup also carries the owner.
Databases speak SQL. There the condition reads WHERE id = $1 AND owner_id = $2.
It means "the invoice with this identifier AND this owner".
The example keeps invoices in memory, without a real database. The condition is the same:
// FindForOwner is equivalent to:
// SELECT id, owner_id, amount FROM invoices WHERE id = $1 AND owner_id = $2
func (s *Store) FindForOwner(id, ownerID string) (Invoice, error) {
s.mu.RLock()
defer s.mu.RUnlock()
inv, ok := s.byID[id]
if !ok || inv.OwnerID != ownerID { // the one-line fix
return Invoice{}, ErrNotFound
}
return inv, nil
}
The fix reads like this. If the invoice does not exist, OR if it belongs to someone else,
answer "not found". Both cases get exactly the same answer. A twin function,
DeleteForOwner, carries the same condition for deletion.
The code that answers a URL is called a route.
Before each route runs a middleware: a filter that reads the token and finds the logged-in user. The route gets that user from the middleware, never from a value Bob typed in the URL. Otherwise Bob would only have to write "I am Alice".
mux.HandleFunc("GET /invoices/{id}", func(w http.ResponseWriter, r *http.Request) {
inv, err := store.FindForOwner(r.PathValue("id"), currentUser(r))
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r) // 404: does not reveal whether the invoice exists
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(inv)
})
Putting the owner in the lookup beats checking afterwards. Checking afterwards means loading the
invoice, then comparing its owner with the logged-in user. If someone forgets the comparison in a
new route, the invoice is sent to Bob. With FindForOwner, the function requires an owner:
a call without one does not even compile; Go refuses to build the program. The code offers no other
way to look up an invoice anyway. The order of middlewares, when there are several, is covered in my article on
Go middleware in production.
The A-B test with httptest
The test starts the whole API, middleware included, with Go's httptest package.
It sends three requests, the ones in the diagram:
- Alice creates an invoice and gets its identifier;
- Alice asks for her invoice again and must get 200;
- Bob asks for the same invoice and must get 404. If he gets 200, the test fails and names the flaw.
Two small helper functions, not shown, keep the code short. createAsAlice does step 1.
call sends a request with Alice's token (token-alice)
or Bob's (token-bob):
func TestBOLA_AB_Read(t *testing.T) {
srv := newAPI()
defer srv.Close()
id := createAsAlice(t, srv) // A: POST /invoices, expects 201
// Alice reads her invoice back. Without this step, an API that
// answers 404 to everyone would pass the test for the wrong reason.
if got := call(t, srv, "GET", "/invoices/"+id, "token-alice", "").StatusCode; got != http.StatusOK {
t.Fatalf("A reads: status %d, want 200", got)
}
// B: Bob asks for Alice's invoice.
got := call(t, srv, "GET", "/invoices/"+id, "token-bob", "").StatusCode
if got == http.StatusOK {
t.Fatalf("BOLA: Bob reads Alice's invoice (200)")
}
if got != http.StatusNotFound {
t.Fatalf("B reads: status %d, want 404", got)
}
}
A second test plays the A-B-A variant: Bob attempts a delete, Alice asks for her invoice again and must still get 200. With the fix in place, both tests are green.
A security test that stays green when you break the code proves nothing. To check it, break the
code on purpose: disable the owner condition in FindForOwner and in its twin.
Then run the tests again. The following output is therefore intended:
--- FAIL: TestBOLA_AB_Read (0.00s)
main_test.go:60: BOLA: Bob reads Alice's invoice (200)
--- FAIL: TestBOLA_ABA_Delete (0.00s)
main_test.go:74: B deletes: status 204, want 404
FAIL
Both tests turn red. The first one names the flaw. In the second, the 204 is the response code for "deleted": Bob really did erase Alice's invoice. Put the condition back, and the tests turn green again. This round trip was run on the article's code.
In Symfony: a Voter and an A-B test
Symfony is a PHP framework, a toolbox for building web applications. It puts the ownership check in a Voter.
A Voter answers yes or no to one question: "may this user read this invoice?".
In the code below, everything happens on the final return line:
final class InvoiceVoter extends Voter
{
public const VIEW = 'INVOICE_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW && $subject instanceof Invoice;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false; // not logged in: deny by default
}
/** @var Invoice $subject */
return $subject->getOwner()?->getId() === $user->getId();
}
}
That line compares the invoice's owner with the logged-in user. The rest is the wrapping Symfony requires from every Voter, here for Symfony 7.3 or later.
The route calls the Voter through the #[IsGranted] line placed above it.
On refusal, Symfony answers 403 by default. The statusCode: 404 option asks for "not found", as in Go:
#[Route('/invoices/{id}', methods: ['GET'])]
#[IsGranted(InvoiceVoter::VIEW, subject: 'invoice', statusCode: 404)]
public function show(Invoice $invoice): JsonResponse
{
return $this->json(['id' => $invoice->getId(), 'amount_cents' => $invoice->getAmountCents()]);
}
One difference from Go, stated plainly. The Invoice $invoice parameter tells Symfony
to fetch the invoice by its identifier first. The Voter rules afterwards.
So this is the check-afterwards approach described above. Its strength: the check lives in a single class, which every route calls by name. To filter at lookup time, you would write your own lookup by identifier and owner.
The test replays the scene of the Go test. WebTestCase boots the application.
loginUser logs Alice or Bob in without going through the login form:
public function testBobCannotReadAliceInvoice(): void
{
$client = static::createClient();
$users = static::getContainer()->get(UserRepository::class);
$alice = $users->findOneBy(['email' => 'alice@example.test']);
$bob = $users->findOneBy(['email' => 'bob@example.test']);
// A: Alice creates an invoice.
$client->loginUser($alice);
$client->jsonRequest('POST', '/invoices', ['amount_cents' => 12000]);
self::assertResponseStatusCodeSame(201);
$id = json_decode($client->getResponse()->getContent(), true)['id'];
// Alice asks for her invoice again: without this step, a 404 everywhere would pass.
$client->request('GET', '/invoices/'.$id);
self::assertResponseIsSuccessful();
// B: Bob asks for the same invoice.
$client->loginUser($bob);
$client->request('GET', '/invoices/'.$id);
self::assertResponseStatusCodeSame(404); // not found: neither 200 nor 403
}
The test assumes Alice and Bob already exist in the test database. It was not run against a real application: only its syntax was checked.
So the proof by red exists only for Go. The creation route is not shown. The A-B-A variant would need a delete route, not written here.
One test per route beats a code review
The temptation: rely on code review, when a colleague reads the changes before they go live. The fix is one line, so the omission is one line too. The colleague reads what is written. A missing line, they do not see.
My article on iterative security audits asked the same question. During an audit, each flaw spotted is called a "finding". The article only kept as a permanent test what answered yes to this question:
Could a future code change, made in good faith by a developer who doesn't know about this finding, reintroduce the vulnerability?
For BOLA, it is yes for every new route that takes an identifier. A developer adds
GET /invoices/{id}/pdf, fetches the invoice by its identifier and forgets the owner.
A commit is a saved set of changes to the code. The same article sums it up in two sentences: "The report is read once. The tests run on every commit." OWASP asks for the same thing: tests on authorization, and no change shipped that makes them fail.
In practice:
- every route that takes an identifier gets its A-B test;
- if that route can modify or delete the invoice, it also gets the A-B-A test.
The test copies from one route to the next; only the URL and the data sent change. The reading notes on Defending APIs cover the rest of the book, and the ones on Web Application Security cover the attacker's side.
Conclusion
As I read it, BOLA stays at the top of the ranking because the flaw leaves no trace in the response. A 200 on Alice's invoice has exactly the same shape as a 200 on Bob's. The difference is not in the response: it is in who asked.
A tool that looks at one response at a time therefore cannot see the flaw. The A-B test asks the question on every commit: who created this invoice, and who is asking for it? Two accounts are enough to ask it.