In this guide 6 sections
A customer sees another account’s records when the server trusts an ID sent by the browser and never asks who owns it. Change 1041 to 1042 in the address bar. A stranger’s invoice opens. The fix is a server side ownership check.
What is really happening behind the address bar
Your portal shows a customer their invoice at an address like /invoices/1041; the page loads because the server looked up record 1041 and handed it over. Nothing in that lookup asked whose record it was. Plain and common.
Testers call this broken object level authorization. The login worked and the session was genuine. What is missing is a second question, asked on every single request and for every record type the portal holds: does this record belong to this account? Attackers script it. A loop counts upward from 1041 and saves every response.
Mobile apps share the hole. The app calls an API. The order ID sits in the path or the JSON body. Anyone running a proxy tool such as Burp Suite, which sits between the app and the server, can edit that ID in seconds and watch what comes back.
- Invoice and statement PDFs fetched by number
- Dealer price lists keyed by dealer ID
- Order tracking pages that accept an order reference
- Report exports with a customer_id parameter
- Upload and download links for delivery documents
How a tester finds it in an afternoon
The method is plain. Create two test accounts, Dealer A and Dealer B. Log in as A. Open an invoice. Copy that request from the browser’s network tab. It needs no special skill and no exploit code.
Now log in as B and replay A’s request using B’s session cookie, leaving the invoice ID exactly as A had it. If B receives A’s invoice, the flaw is confirmed. Repeat for every screen that takes an ID, including edit, delete and download actions, since a tester must cover each route one by one.
Writes matter more than reads. If B can change A’s delivery address, the damage goes past a leak.
- Two accounts at the same permission level
- Two accounts at different levels, such as dealer and staff
- A logged out request, to see what an anonymous visitor gets
- Numeric, guessable IDs first, then any IDs found in other responses
Why longer or hidden IDs do not fix it
Teams often swap 1041 for a long random string. They call it solved. That slows guessing, but it removes nothing, because a forwarded link, an old email or a shared screenshot still carries a working ID. Sequential numbers are the worst case. Guessing needs one line of script.
Hiding the button fails the same way. The server answers any request it receives. The screen is only a suggestion.
Random identifiers are a fair extra layer for a dealer portal where price lists and credit limits must stay private. Treat them as a bonus and never as the lock.
The server side checks that close it
Every query that fetches or changes a record must carry the account in its filter, including the quiet ones behind export buttons and scheduled reports. Instead of loading invoice 1041, load invoice 1041 where customer_id equals the signed in customer, and let a record that is not theirs return not found.
Put the rule in one shared place. A policy class, a query scope or middleware works, so that nobody can forget it on a new endpoint. Give staff and dealer roles their own rules. Skip the catch all admin flag. Frameworks help here. Laravel policies, Django permissions and Express middleware each give the rule one home.
Send one reply for missing and forbidden alike. Otherwise an attacker learns which IDs exist, which is a leak of its own. Do the same for file downloads. A statement PDF sitting in a storage bucket needs a signed link that expires within minutes and is tied to the user who asked for it.
- Scope every database query by the owning account
- Derive the account from the session, never from a request field
- Check ownership again on edit, delete and export routes
- Keep file storage links short lived and tied to the user
- Log denied requests so repeated probing is visible
Prove the fix and keep it fixed
After the patch, repeat the two account test on every route and keep those cases as automated tests. A new endpoint that skips the check fails the build.
Look backwards too. Search access logs for one session requesting long runs of sequential IDs, because that pattern means someone already walked your records. Short sessions. Many calls. Low pace.
If you find it, list the exposed accounts. Tell those customers. Today, ask your developer for every route that takes an ID, then test each with two accounts. Tell the people who run support, too. They hear about odd invoices first.
Two account access test sheet
A single page worksheet. One row per route that takes an ID. Columns hold the route, the method and the ID parameter. Further columns give the result when Dealer A requests Dealer B’s record, the result when logged out, the fix applied and the retest date. A footer box records the sign off and the automated test covering the route.
Open a blank worksheet to printQuestions owners ask
Is this the same as a hacked password?
No. The attacker holds a valid login of their own and simply asks for records that belong to somebody else. Strong passwords do not stop it.
Can a firewall block this?
Rarely. The requests look normal. Only the application knows which record belongs to which account, so the check must live there.
How do I know if it already happened?
Check access logs for one account requesting many different IDs in sequence; look also for responses that returned a record owned by another account. Thin logging makes proof hard.
Does this apply to a bought package?
Yes. Packages and plugins carry the same flaw. Test them with two accounts, then send the vendor the exact request that leaked.
