Resources/Research

Findings from 120 API pentests: where authorization breaks

Object-level access control failures accounted for the largest share of high-severity findings this year. Patterns and fixes.

2026-09-048 min readSecureBlock research team

Between September 2025 and August 2026 SecureBlock completed 120 API penetration tests across fintech, SaaS and healthcare clients. We reviewed every high and critical finding to see where authorization fails in practice.

The numbers

  • 41% of high and critical findings were broken object-level authorization (BOLA / IDOR).
  • 18% were broken function-level authorization: regular users reaching admin endpoints.
  • 12% involved mass assignment or property-level authorization.
  • Only 6% were injection flaws.

Why object-level checks fail

The most common cause was not a missing check but an inconsistent one. Teams protect the obvious endpoint, such as GET /accounts/{id}, and forget the export, the bulk operation, the webhook replay or the GraphQL resolver that reaches the same record.

http
GET  /api/v2/accounts/4821                 -> 403 (checked)
GET  /api/v2/accounts/4821/export          -> 200 (not checked)
POST /api/v2/reports {accountIds:[4821]}   -> 200 (not checked)

Same record, three routes, one check.

The vulnerable handler usually looks reasonable in isolation. The check exists on the read endpoint and is simply absent on the export:

javascript
// accounts.js
router.get('/accounts/:id', auth, async (req, res) => {
  const acct = await Account.findById(req.params.id);
  if (acct.orgId !== req.user.orgId) return res.sendStatus(403);
  res.json(acct);
});

router.get('/accounts/:id/export', auth, async (req, res) => {
  const acct = await Account.findById(req.params.id);   // no ownership check
  res.attachment('export.csv').send(toCsv(acct));
});

Vulnerable: authorization is repeated per handler and missed on one.

javascript
// authz.js
async function loadOwnedAccount(req, res, next) {
  const acct = await Account.findOne({ _id: req.params.id, orgId: req.user.orgId });
  if (!acct) return res.sendStatus(404);
  req.account = acct; next();
}

router.get('/accounts/:id', auth, loadOwnedAccount, (req, res) => res.json(req.account));
router.get('/accounts/:id/export', auth, loadOwnedAccount, (req, res) =>
  res.attachment('export.csv').send(toCsv(req.account))
);

Fixed: ownership is part of the query, and every route uses the same loader.

What worked

Clients with the fewest authorization findings shared three practices: a single authorization layer called from every handler, integration tests that run each endpoint as two different tenants, and a resource model where ownership is explicit in the data rather than inferred from the URL.

javascript
test.each(routes)('%s rejects cross-tenant access', async (route) => {
  const acct = await createAccount({ org: tenantA });
  const res  = await request(app)[route.method](route.path(acct.id))
                 .set('Authorization', tokenFor(tenantB));
  expect([403, 404]).toContain(res.status);
});

Tenant-pair test run against every route in the API.

Testing recommendation

Ask for two accounts in two tenants at scoping time. A gray-box API test with tenant pairs finds these issues in hours; a black-box test may never reach them.

Want this checked against your environment?