Blog Application Security

Change one number in the URL. See someone else’s invoice.

Log in to an app. Open one of your invoices. Look at the address bar:

https://app.example.com/invoices/48213

Now change 48213 to 48214.

If you see somebody else’s invoice, you have found one of the most common serious flaws in web software. You didn’t need a tool, an exploit or any special knowledge. You only needed to count.

Broken access control sits at number one on the OWASP Top 10, the industry’s best-known list of web application risks. This specific form of it, called Broken Object Level Authorization, or BOLA, is first on the OWASP API Security Top 10 as well. You may also know it by its older name, IDOR.

Why your login doesn’t stop it

Two questions get mixed up here:

  • Who are you? That is authentication, and most apps handle it well.
  • Is this yours? That is authorization, and it has to be answered again for every single record.

The bug usually looks perfectly reasonable in code review:

// Vulnerable: any logged-in user can read any invoice.
app.get('/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.query(
    'SELECT * FROM invoices WHERE id = $1',
    [req.params.id]
  );
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

It checks the login. It uses a parameterised query, so there is no SQL injection. It handles the missing record. A reviewer might approve it without a second thought.

It just never asks whose invoice this is. The fix is short:

// Fixed: the record must belong to the person asking.
app.get('/invoices/:id', requireLogin, async (req, res) => {
  const invoice = await db.query(
    'SELECT * FROM invoices WHERE id = $1 AND account_id = $2',
    [req.params.id, req.user.accountId]
  );
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

The account ID comes from the server’s session, never from the request. Returning 404 here also avoids confirming that a record exists when it belongs to somebody else.

Why your scanner probably won’t find it

To an automated scanner, the vulnerable response looks like success: a 200 status and well-formed JSON, with no error and no strange behaviour.

Spotting the problem requires knowing that invoice 48214 belongs to a different customer. That is a fact about your business, and it isn’t visible in the HTTP traffic. It takes a person, or a test written by one, who knows what each user should be able to see.

“We use random IDs, so we’re safe”

Random IDs help. OWASP recommends them, and they stop anyone from simply counting through your records.

They do not fix the bug. IDs leak all the time: in shared links, emails, browser history, logs, support tickets, and in other API responses that list them. Once somebody has an ID, the missing check is still missing. Treat random IDs as a second layer and keep the ownership check.

Where it hides

The main invoice page is often fine, because everybody tests it. Look in the quieter places:

  • Writes. The GET checks ownership, but do PUT, PATCH and DELETE?
  • Exports and downloads. PDF generators and CSV exports are often added later, by a different route.
  • Nested routes. In /accounts/7/invoices/48214, does anything confirm the invoice belongs to account 7, and that you belong to account 7?
  • Mobile APIs. Older versions stay online long after the app has moved on.
  • GraphQL. Each object you can reach through a query needs its own check, including ones reached through relationships.
  • Bulk actions. An endpoint that takes a list of IDs might only check the first one.
  • Files. Uploaded documents often sit behind a URL with no check at all.
  • Background jobs. “Email me this report” may run later, without the original user’s permissions.

Test it yourself in ten minutes

Do this on your own application, in a test environment.

  1. Create two users, Alice and Bob, in two separate accounts.
  2. As Alice, use the app normally with your browser’s network tab open. Note every request that contains an ID.
  3. Replay each of those requests with Bob’s session, keeping Alice’s IDs.
  4. Every one of them should fail. Any that succeed are findings.

Then keep the protection by turning that test into code, so a future change can’t silently remove it. OWASP’s advice is to write authorization tests and refuse to deploy changes that fail them:

test('users cannot read another account\'s invoice', async () => {
  const invoice = await createInvoice({ account: alice });

  const response = await api
    .get(`/invoices/${invoice.id}`)
    .auth(bob);

  expect(response.status).toBe(404);
});

Fix the pattern, not the instance

If each developer has to remember the check on each route, one day somebody won’t. Better designs make the safe path the easy one:

  • Scope queries by default. Fetch through the current account, such as account.invoices.find(id), so a global lookup is the unusual thing that stands out in review.
  • Deny by default. OWASP’s guidance is that anything except public resources should be denied by default, with access granted explicitly.
  • Keep the rules in one place. A single policy layer is easier to review and test than checks scattered through hundreds of handlers.
  • Consider database enforcement. Row-level security can provide a second barrier when application code gets it wrong.

Start with the ten-minute test. It costs almost nothing, and you will know the answer by the end of the day.