VAPT & Penetration Testing
How APIs leak other users' data, and how to stop it
Changing one ID in a request can show someone else's record. Learn what broken object-level authorisation is and how to fix it in your API.
Many apps leak data in one simple way. A signed-in user asks for a record, the server hands it over, and nobody checks whether the record belongs to that user. If a customer can change one ID in a request and see someone else's order, invoice or profile, your API has this flaw.
OWASP lists it first in its API Security Top 10 (2023) (opens in a new tab) as API1, Broken Object Level Authorization, often shortened to BOLA. It is also called IDOR. This post explains what it looks like, how to close it in a Next.js route handler, and how to test your own app with two accounts. If you build or run an app with logins, read it before your next release.
What the flaw looks like
Picture an invoice page that loads from an address like /api/invoices/1042. You sign in, open your invoice, and the app asks the server for number 1042. The server finds it and returns it.
The flaw is what happens when a signed-in user asks for number 1043 and the server returns that too, though it belongs to someone else. The user did nothing clever. They only changed a number.
OWASP describes it this way: attackers can exploit endpoints that are vulnerable to broken object level authorization by manipulating the ID of an object that is sent within the request. This can happen in the address, in a query parameter, in the body of a request or in a header. It can lead to data disclosure, and where the endpoint also changes or deletes data, to changes and deletions as well. OWASP calls the issue extremely common in API-based applications, because the server usually does not fully track the client's state and relies on IDs the client sends.
Automatic scanners rarely find it. A scanner does not know that invoice 1043 belongs to someone else. Only a check that knows who owns what can tell.
Why a login is not enough
Authentication answers "who are you?". Authorisation answers "may you see this?". Many apps do the first and forget the second.
A common mistake is to protect the route with a login check and stop there. Every signed-in user then passes the check, for every record. The login proves a person is a customer. It does not prove the record is theirs.
OWASP makes a related point. Every endpoint that receives an ID of an object and performs any action on the object should implement object-level authorisation checks. It also warns that comparing the user ID from the session, for example one taken from a JWT, with the ID in the request is not by itself a sufficient solution. The check has to be tied to the actual record and the rules for who may use it.
Be careful with the same pattern in other places, such as update and delete endpoints, file downloads, exports, and any search that takes a record ID.
Check ownership on every request
The fix is to load the record and check that the signed-in user may access it, every time, in the server code. Do not trust the client to send only IDs it should.
OWASP's guidance is to use the authorisation mechanism to check that the logged-in user has access to perform the requested action on the record in every function that uses an input from the client to access a record in the database.
Here is a short defensive example for a Next.js App Router route handler. The functions getSessionUser and findInvoiceById are placeholders for your own authentication and database calls. Replace them with whatever your app uses.
// app/api/invoices/[id]/route.ts
import { NextRequest } from 'next/server'
// PLACEHOLDER: replace with your real auth call.
// Returns the signed-in user, or null if nobody is signed in.
declare function getSessionUser(
req: NextRequest
): Promise<{ id: string } | null>
// PLACEHOLDER: replace with your real database call.
// Returns the record, or null if it does not exist.
declare function findInvoiceById(
id: string
): Promise<{ id: string; ownerId: string; total: number } | null>
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser(req)
if (!user) {
return Response.json({ error: 'Unauthorised' }, { status: 401 })
}
const { id } = await ctx.params
const invoice = await findInvoiceById(id)
// Same answer for "missing" and "not yours", so IDs cannot be probed.
if (!invoice || invoice.ownerId !== user.id) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(invoice)
}
Three points about this code.
- The
ownerIdcomparison runs on the server, against the stored record. It does not use anything the client claims. - The route reads
paramswithawait, because in current versions of Next.js the route context passesparamsas a promise. - Even better than loading then comparing is to build the ownership rule into the query itself, for example "find invoice where id matches and owner matches the user". Then no other user's record is ever loaded.
For apps with teams or roles, replace the simple owner comparison with a rule that fits, such as "the user belongs to the organisation that owns the invoice". Keep that rule in one shared function, so every route uses the same one.
Do not rely on hard-to-guess IDs
Random IDs, such as UUIDs, are a good idea. OWASP recommends preferring random and unpredictable values as GUIDs for record IDs. They stop people from simply counting from 1042 to 1043.
They are not a lock, though. IDs leak. They appear in links, emails, browser history, logs and shared screenshots. Anyone who gets hold of one can try it. If the server does not check ownership, a random ID only slows the problem down. Use random IDs and the ownership check together.
Return the right errors
The status code you return tells a caller something.
- 401 means nobody is signed in.
- 403 means the user is signed in but not allowed.
- 404 means the record was not found.
For records that belong to other users, many teams return 404, as the example does. A 403 would confirm that the record exists, which helps someone who is guessing IDs. Whichever you choose, use the same answer for "does not exist" and "not yours" where the existence of the record is itself private.
Keep error messages short. Do not include the ID owner, the database query or a stack trace in the response.
Test your own API with two accounts
You do not need special tools. You need two accounts on your own app, ideally on a staging copy with fake data.
- Create account A and account B, each with a few records.
- Sign in as account A and note the ID of one of its records.
- Sign in as account B and try to open account A's record.
- Check the result. You should get a "not found" or "forbidden" response and none of A's data.
- Repeat for every endpoint that takes an ID, including update, delete, download and export.
- Repeat for each user role, such as an ordinary user against an admin's record.
Turn this into automated tests. OWASP advises writing tests that evaluate the authorisation mechanism and not deploying changes that make the tests fail. A test that creates two users and checks that one cannot read the other's record takes little time to write and guards every future release.
Only run these checks on apps you own, with accounts you created.
When to get an outside test
Your own tests catch the cases you thought of. An outside tester looks for the ones you did not, such as an old endpoint nobody remembers, an export route that skips the check, or a mobile app that calls a different API from the website. Consider it before launch, after a large change to login or roles, and when you handle payments, health data or other sensitive records.
Next step
Hackers Family offers penetration testing with written permission, findings ranked by risk with a clear fix, and a re-test after fixes. If you need the fixes built, see custom software.
