Blog/Cybersecurity

API authorization, object by object

Broken object level authorization is the finding we raise most often on API engagements, and one of the few you can design out for good rather than patch endpoint by endpoint.

On a lot of API tests, the first serious finding comes from nothing cleverer than changing one number. We sign in as a real customer, open their invoice at /api/invoices/4471, then ask for 4470, and the server returns someone else’s document without a word of complaint.

This is broken object level authorization, API1 in the OWASP API Security Top 10 for 2023. It sits at the top of that list because it is both common and high impact, and we find it in systems that are otherwise carefully built. The reason it survives good engineering is the part worth understanding, because it decides how you fix it.

In short

  • A valid token says nothing about whether this caller may touch this object. That check is yours to write, and it is the one that gets skipped.
  • Push the ownership check into the data layer as a scoped query, so an unowned row never leaves the database.
  • Derive the tenant and the user from the server-side session, never from an id the client sent.
  • A UUID raises the cost of guessing and proves nothing about access. Treat it like a username.
  • GraphQL and batch endpoints widen the surface. The check belongs on every nested resolver and every item in a list.
  • Test with two users in two tenants on every build. The oracle is simple enough to parameterize across your whole route list.

Authentication answers the wrong question

Your framework gives you authentication almost for free. A middleware validates the token, loads the user, and turns away anyone who is not signed in. That middleware answers one question: who are you. It says nothing about whether you may read this particular record, and no off-the-shelf middleware can, because record ownership lives in your business logic, not in theirs.

So the check that decides the whole thing is the one nobody wrote. The handler receives a valid token, trusts it, reads the id from the path, and fetches the row. The caller is authenticated. The caller is also reading an account that belongs to someone else.

Here is the shape we see on test after test:

// vulnerable: authenticated, but never authorized for THIS invoice
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id);
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

requireAuth did its job and confirmed a logged-in user. Nothing in those four lines confirms the invoice belongs to them. For a bank or a government portal, where the object behind that id is a statement, a national ID record or a payment instruction, the gap is the whole report.

Where the check belongs

Before the fix, be precise about where in a request the ownership decision should sit, because teams often put it in the wrong layer and then wonder why it keeps slipping.

  1. 01TransportTLS terminates, the request reaches the app
  2. 02Authenticationtoken validated, user loaded onto the request context
  3. 03Routingpath and method matched to a handler
  4. 04Handlerreads the invoice id, delegates to the data layer
  5. 05Data layerquery scoped to the owner, so the row is filtered at the source
  6. 06Responsean object belonging to someone else is never selected, so it cannot be returned
A request for an invoice, and the one layer where the owner check holds

The decision belongs in the data layer, bound to the query itself. Put it in the handler and you depend on every handler author remembering it. Put it in an API gateway or an edge rule and it cannot see object ownership at all, because the gateway does not know which customer owns invoice 4470. The closer the check sits to the data, the fewer places it can be forgotten.

Where it hides

If this were only sequential integers in a path, scanners would catch most of it. It is rarely that tidy.

IDs travel in request bodies and query strings, not only in paths, and those get reviewed less. A PATCH that carries {"accountId": 4470, ...} is the same flaw in different clothes. Batch and bulk endpoints multiply it: an endpoint that accepts a list of IDs has to check every one, and the loop that forgets a single item is easy to write. GraphQL widens the surface again, because one query can walk from an object you own into a nested one you do not, and the resolver for that nested field is where the check belongs.

Then there are endpoints built for internal use that became reachable without anyone deciding so: admin views, support tools, export jobs, the /internal/ route someone put behind a header check that a proxy now strips. Nested resources invite a half-check. On /api/accounts/{accountId}/statements/{statementId} it feels natural to verify the account and assume the statement under it is fine, but if statement IDs are global, a valid account of yours plus somebody else’s statement ID walks straight through.

One belief we spend real time correcting: a UUID is not an access control. A UUID is harder to guess than 4470, and that raises the cost of blind enumeration. It does nothing once the identifier leaks, and identifiers leak constantly, through shared links, referrer headers, server logs, support tickets, a mobile app’s own traffic. Treat a UUID like a username. It names the object. It does not prove you may have it.

Identifiers also reach the server through channels that never appear in the API spec. A webhook replays an object id back to you, a nightly export emails a link containing one, a push notification deep-links straight to a record, a mobile client caches a response holding a dozen more. Each is another place an id can be swapped for a neighbor’s. In Saudi products the stakes run higher than usual, because the identifiers in play are often national ID or iqama numbers and IBANs, and under the open banking arrangements SAMA has pushed, one institution’s API gets called on behalf of a customer by another. An authorization gap there leaks across an integration rather than exposing a single record.

Scope the query, not the handler

The durable fix makes the unscoped query hard to write in the first place, rather than bolting a guard onto each handler. Compare the two patterns:

// fetch, then check: one forgotten guard and it leaks
const invoice = await db.invoice.findById(id);
if (invoice.ownerId !== req.user.id) return res.sendStatus(404);

// scoped query: a row for the wrong caller is never selected
const invoice = await db.invoice.findOne({ id, ownerId: req.user.id });
if (!invoice) return res.sendStatus(404);

The second version carries the authorization inside the query. A developer who copies it to a new endpoint carries the check along. The first version only works if every future reader remembers the guard, and some will not.

A few habits keep this holding under pressure. Deny by default, so a request with no matching ownership rule is refused rather than allowed. Keep one authorization function per resource type, canAccessInvoice(user, invoice), called in one place, rather than ad hoc comparisons scattered through handlers.

Multi-tenancy raises the stakes, because now a mistake leaks across companies, not only across users in one company. The rule we repeat on every bank and SaaS engagement: derive the tenant from the server-side session and scope every query by it. Never trust a tenantId the client sent, because a client that supplies its own tenant can supply a different one.

// the tenant comes from the session, never from the request body or a header
const tenantId = req.session.tenantId;
const account = await db.account.findOne({ id, tenantId });
if (!account) return res.sendStatus(404);

If your ORM supports it, enforce the tenant scope one level lower still, in a global query filter or at the database with row-level security, so even a query that forgets the clause comes back empty rather than wrong. That second layer is worth the setup cost when the blast radius is another customer’s data.

Two caveats keep this honest. Ownership is sometimes many-to-many: a document shared with a team, an account with several authorized signatories, a joint facility with two named holders. Model that sharing as its own table and scope the query through it, rather than widening the ownerId check into something fuzzy that grants more than you meant. And the scoped query is usually the faster one anyway, because the owner or tenant column is exactly what you want indexed, so you rarely pay for the safety with latency.

404 or 403

There is one deliberate trade-off worth naming. Returning 404 instead of 403 for an object that exists but is not yours avoids confirming that it exists, which is the right default for anything sensitive. The cost is harder support and debugging, because a legitimate user who hits a real permissions problem sees the same blank response as someone probing.

Decide per resource. A public blog post can afford an honest 403. A customer’s account balance should not confirm its own existence to the wrong person, so it returns 404. Whatever you choose, keep it consistent within a resource type, because a 403 on one id and a 404 on another for the same endpoint leaks exactly the existence signal you were trying to hide.

GraphQL and batch endpoints

Two shapes deserve their own code, because the single-object pattern above does not cover them cleanly.

In GraphQL, the top-level query is rarely the problem. The nested resolver is. A caller fetches an account they own and reads its statements field, and if that resolver pulls statements by account id alone, it has trusted the parent and skipped its own check.

const resolvers = {
  Query: {
    account: (_root, { id }, ctx) =>
      db.account.findOne({ id, ownerId: ctx.user.id }),
  },
  Account: {
    // the nested resolver carries its own scope, not the parent's trust
    statements: (account, _args, ctx) =>
      db.statement.findMany({ accountId: account.id, ownerId: ctx.user.id }),
  },
};

Batch endpoints fail the opposite way, by looping and trusting. Scope the whole set in one query, then confirm the count, so a request that mixes in ids the caller does not own comes back short rather than full.

app.post('/api/invoices/bulk', requireAuth, async (req, res) => {
  const ids: string[] = [...new Set<string>(req.body.ids ?? [])];  // duplicates would skew the count
  const invoices = await db.invoice.findMany({
    id: { in: ids },
    ownerId: req.user.id,
  });
  if (invoices.length !== ids.length) {
    // at least one id was not the caller's; treat the batch as a bad request
    return res.sendStatus(404);
  }
  res.json(invoices);
});

Neither of these can be fixed at the API gateway, which sees a single POST to /graphql and cannot tell an authorized field from an unauthorized one. The check has to live in the resolvers and the data layer, the same as everywhere else. For the data-aggregation products common in GCC fintech, where one query fans out across many accounts and institutions, that discipline is the line between a feature and an incident.

Prove it on every build

The useful thing about this class of bug is that it tests cleanly, because the oracle is simple: a caller must never reach an object they do not own. That is a loop, not a judgment call, and a loop belongs in CI.

Set up at least two users in two tenants so you cover same-tenant and cross-tenant access in one matrix. For a single owned endpoint, the cases you want are small and total:

CallerObject ownerRelationshipExpected
User A1User A1owner200, the object
User A2User A1same tenant, different user404
User B1User A1different tenant404
No tokenUser A1unauthenticated401

Write it once as a parameterized matrix over your endpoint list rather than by hand per route. A new endpoint that was added without being registered in the list then shows up as an obvious gap in review.

const ownedEndpoints = [
  { name: 'invoice',   create: createInvoice,   path: (id: string) => `/api/invoices/${id}` },
  { name: 'statement', create: createStatement, path: (id: string) => `/api/statements/${id}` },
  { name: 'card',      create: createCard,      path: (id: string) => `/api/cards/${id}` },
];

describe.each(ownedEndpoints)('BOLA: $name', ({ create, path }) => {
  test('same-tenant other user is refused', async () => {
    const obj = await create(userA1);                 // owned by A1
    const res = await api(userA2).get(path(obj.id));  // A2, same tenant
    expect(res.status).toBe(404);                      // not 200, and not the body
  });

  test('cross-tenant user is refused', async () => {
    const obj = await create(userA1);
    const res = await api(userB1).get(path(obj.id));  // B1, other tenant
    expect(res.status).toBe(404);
  });
});

Assert more than the status code. A handler that returns 404 but still performed the write, or that leaks the object inside an error body or a log line, has failed in a way a status check misses, so the test confirms the object did not change and the body carried nothing it should not. Cover the write verbs as well, because a read-only suite says nothing about whether user B can PATCH or DELETE user A’s record, and those are the operations that cause the real harm.

The test that earns its place is the boring one that runs on every pull request, because this flaw returns the moment someone adds a feature and forgets the scope.

The neighbors, and a checklist for review

Two items sit next to BOLA on the same list and share its root habit of trusting an authenticated request too far.

API3:2023, broken object property level authorization is the field-level version. The caller may read the object but is handed properties they should not see, or can write properties they should not set. Mass assignment is the common write form, where a handler binds the whole request body to the model and a request silently updates role or isVerified. An allowlist of writable fields closes it:

// reject: binds the whole body, so role and isVerified ride along
await db.user.update(req.params.id, req.body);

// prefer: only named fields are writable, and a user can only update themselves
const writable = ['displayName', 'phone', 'language'] as const;
const patch = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => writable.includes(k as never))
);
await db.user.update({ id: req.user.id }, patch);

API5:2023, broken function level authorization is the operation-level version: a standard user reaching an admin-only action because the route was never restricted. The fix is a role check in code at the handler, not a route that is merely unlisted or sitting behind a header a proxy can strip.

Fold all of it into the one place a regression gets caught cheaply, the pull request:

  • Every handler that takes an object id runs a query scoped by owner or tenant, not a fetch-then-compare.
  • Tenant and user identity come from the server-side session, never from a body, query string or header.
  • Write endpoints use an allowlist of fields. role, isVerified, ownerId and tenantId are never client-writable.
  • Batch endpoints scope the whole set and verify the returned count matches the requested ids.
  • GraphQL resolvers for nested and list fields each enforce ownership, not only the top-level query.
  • Admin and internal operations are gated on a role check in code, not on an unlisted route or a stripped header.
  • A cross-user and cross-tenant test exists for the endpoint and runs in CI.

One habit pays for itself across all three. Log authorization failures as their own event and watch them. A single user who collects dozens of 404s across varied object ids in a minute is enumerating, and that pattern is visible well before any data leaves, provided someone is looking at it. The same log is the evidence a SAMA or NCA reviewer asks for when they want to know how you would detect this class of abuse in production, so the monitoring earns its keep twice.

If you want one thing to do this week, take your three or four most sensitive endpoints, the ones that return money, personal data or documents, and write the cross-user test for each by hand. You learn two things fast: whether the flaw is there, and whether your codebase makes the scoped query the easy one to write. The second answer tells you how much of the rest of your API carries the same risk.

Sources

  1. OWASP API Security Top 10 2023, OWASP, 2023.
  2. API1:2023 Broken Object Level Authorization, OWASP, 2023.
  3. API3:2023 Broken Object Property Level Authorization, OWASP, 2023.
  4. API5:2023 Broken Function Level Authorization, OWASP, 2023.
inX
AS

Written by

EhsanLab AppSec TeamApplication security and API testing

The team that tests web and mobile APIs for banks, fintechs and government platforms, and works with developers on the fixes.

EhsanLab

Want us to look at your systems?

Tell us what you are building and what worries you about it. A specialist replies within one business day.