Skip to content

Insufficient object level authorization in APIs

CWE-639CWE-285OWASP A01:2021Updated September 4, 20265 min read

With insufficient object level authorization, an API checks that the requester is logged in but not that the requested object belongs to them. By altering the identifier in the URL, a valid user reaches other people's data. It is the most common and most exploited weakness in APIs.

Of all API weaknesses this is the most common and at the same time the least conspicuous: nothing breaks, no error appears, and the request is technically entirely correct. Only the requester receives data that is not theirs. This article explains why the mistake is so persistent and how to rule it out structurally.

What is object level authorization?

Every request to an API needs two different questions answered. The first is: who are you? That is authentication, and it is nearly always handled well. The second is: may you access this specific object? That is object level authorization, and it is regularly skipped.

Insufficient object level authorization, known internationally as broken object level authorization or BOLA, means the API checks the first and not the second. Anyone with a valid account can then reach the data of every other account, simply by altering the identifier in the request.

Think of a mailroom where you identify yourself properly on entry and may then open any pigeonhole you point at. The identification was genuine, the clerk did their job, and yet you are standing in someone else’s post. The check that is missing is not “who are you” but “is this box yours”.

How is broken object level authorization exploited?

Vulnerable:

// It checks whether someone is logged in, and nothing else
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoices.find(req.params.id);
  if (!invoice) return res.sendStatus(404);

  res.json(invoice);
});

The requireLogin middleware does exactly what its name says: it refuses requests without a valid session. What it does not do is establish whether this invoice belongs to this user. A logged-in customer therefore only has to change the number:

GET /api/invoices/10432 HTTP/1.1     ← own invoice
GET /api/invoices/10433 HTTP/1.1     ← another customer's invoice
HTTP/1.1 200 OK

Both requests succeed. And because this is an API, it is trivial to automate: a loop over all the numbers yields the complete invoice administration. If a rate limit is missing too, a full data breach is a matter of minutes, carried out with a valid account and without a single error in your logs.

Safe:

// The owner is part of the query, not a check afterwards
app.get('/api/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.invoices.findForCustomer(
    req.params.id,
    req.user.customerId,          // from the session, never from the request
  );
  if (!invoice) return res.sendStatus(404);   // same answer as 'does not exist'

  res.json(invoice);
});

The difference is small in code and large in consequence. The owner now sits in the query itself, and that owner comes from the session, not from a parameter the requester can alter. If the invoice exists but belongs to someone else, nothing comes back.

Note the chosen response: 404 and not 403. A 403 confirms that the object exists, and that in itself is information an attacker can use to establish which identifiers are in use.

In a growing application, repeating that check per endpoint eventually becomes untenable. It is more robust to enforce it in a place that cannot be forgotten:

// Every query on this repository automatically carries the owner
const invoicesFor = (user) => ({
  find: (id) => db.invoices.first({ id, customer_id: user.customerId }),
  list: () => db.invoices.all({ customer_id: user.customerId }),
});
Never trust an owner identifier that sits in the request itself. A parameter such as customerId or a field in the JSON body is determined by the requester and is therefore no proof of ownership. The only reliable source is the verified session or the verified token.

What is the impact of broken object level authorization?

The severity is high to critical, and with this finding that is nearly always justified. The reason is the combination of simplicity and scale.

The attack requires no special knowledge: a valid account and changing a number suffice. There is no injection, no detour and no exploit involved. And because APIs are designed for machine use, the attack automates effortlessly into a full read-out of the dataset. Many large data breaches at mobile applications and customer portals arose along exactly this route.

If the endpoints also write, it does not stop at reading. A PUT or DELETE without an ownership check means a user can alter or delete other people’s data. With an endpoint that adjusts roles or rights, the road to elevating one’s own privileges lies open.

What hampers detection: there is nothing out of the ordinary to see. Every request is authenticated, correctly formed and answered with a 200. Without monitoring the pattern, one account requesting hundreds of different objects, it does not stand out.

How do you detect broken object level authorization?

The test requires two accounts and method. A tester logs in as user A, walks through the application and records every request containing an identifier. Then they repeat those requests with user B’s token. Every response returning A’s data is a finding.

That happens not only for retrieval. The changing and deleting endpoints are often forgotten, while the consequences there are greater. Testers also look at identifiers that sit not in the path but in the body, in a header or in a nested object; an orderId deep in a JSON structure is rarely checked. Endpoints processing several objects at once get tested too, where the check is sometimes applied only to the first element. And differences in the response are noted: a 403 where a 404 normally appears reveals which identifiers exist. AssistSec runs this test with accounts in different roles and between different customers in a shared environment, because the separation between customers is what most often proves incomplete in practice.

How do you prevent broken object level authorization?

  • Check on every request whether the requested object belongs to the authenticated user.
  • Include the owner in the database query itself rather than comparing afterwards.
  • Take the owner identifier only from the session or token, never from the request.
  • Enforce the check centrally, for instance in a repository layer, so a new endpoint cannot forget it.
  • Answer with 404 rather than 403, so the existence of objects is not confirmed.
  • Test every method separately: retrieving, changing, deleting and bulk operations.
  • Check identifiers that sit in the body or in nested structures as well.
  • Use unpredictable identifiers as an additional layer, never as a replacement for authorisation.
  • Monitor for accounts requesting an unusual number of different objects.

Sources

Frequently asked questions

Does using UUIDs instead of sequential numbers help?

It makes guessing harder, but it solves nothing. Identifiers leak through shared links, exports, log files and other endpoints in the same application. Once an attacker knows one, the attack works unchanged. Unpredictable identifiers are a useful addition, not authorisation.

Why is this so common in APIs?

Because an API is often organised per resource and the authorisation has to be repeated per endpoint. One new endpoint where the check was forgotten is enough. In a web interface it is also masked, because the user only sees their own links; in an API that shield is absent.

What is the difference with BFLA?

BOLA is about whether you may access this object; BFLA is about whether you may perform this function at all. The first is an ordinary user reaching someone else's record, the second an ordinary user calling an admin function. Both must be tested separately.

How do I test this systematically?

With two accounts. Perform an action as user A, record the request, and repeat it with user B's token. If B sees A's data, the finding stands. Do that for every endpoint accepting an identifier, including changing and deleting.

Related articles

Press / to search · Esc