What an attacker sees before they touch your site

Your front-end is calling an API that hands back everyone's data, not just the logged-in user's

Your site looks fine. The page renders, the dashboard shows your name and your orders, nothing is obviously wrong. But while that page was loading, the browser quietly fired a request to one of your own API endpoints, and the response that came back held more than your page needed. Not just your record. Everyone's.

This is the most common serious flaw in web APIs, and it has a dull name: broken object-level authorization, or broken access control. The idea is simple. An endpoint returns data, but it never checks whether the person asking is allowed to see it. The front-end only renders one user's slice, so nobody notices the rest is in the response too. The padlock in the address bar is green. The leak is in the JSON behind it.

What the leak actually looks like

It rarely looks like a breach. It looks like a perfectly normal API call that returns a list. A /api/users that should return your team returns the whole table. A /api/orders scoped to "recent" returns everyone's. An autocomplete endpoint that should match three results returns the full directory. The response is well-formed JSON; it is just answering a question nobody was authorized to ask.

request
GET /api/users?page=1 HTTP/1.1 Host: app.yoursite.com (no auth required)
response
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":1,"name":"Dana Okafor","email":"[email protected]","role":"admin"},
{"id":2,"name":"Sam Reyes","email":"[email protected]","role":"user"},
... 847 more records ...
One unauthenticated request, 849 real people's names, emails, and roles. The page only rendered the first three.

The tell is cardinality. One record in a response is a profile page, and that is fine. Many records, each carrying a name or an email or a phone number, returned to a request that carried no login, is the broken-access-control shape. A handful of people's personal data in one public response is not a coincidence of how the UI is built; it is the endpoint failing to ask who is calling.

Why it happens, and why it survives to production

It is almost always a missing check, not a wrong one. The endpoint was written to return data, the developer tested it while logged in as an admin who could see everything, and it worked. The per-user filter, the "only return rows this person owns" clause, was the thing that never got added, because the happy path looked complete without it. Frameworks make fetching all rows the one-line default and scoping them the thing you have to remember.

The over-fetch has a sibling: the fat serializer. An endpoint returns a user record and, instead of picking the two fields the UI shows, it serializes the whole database row. Now the response carries the internal fields too, the is_admin flag, the role, the private notes, attributes the front-end never renders but every visitor can read in the network tab. None of it is on screen. All of it is in the response.

0:00Attacker opens the network tab on your page
0:20Sees /api/users return 800+ records
0:45Replays the call, pages through every record
1:30Full user list: names, emails, roles, exported
No exploit, no payload. The endpoint answered a question it should have refused.

A leaked user list is not harmless even without passwords. It confirms which accounts exist, it is a ready-made target list for phishing, and a returned role or is_admin field tells an attacker exactly who to go after. When the response over-shares an entitlement flag, a is_premium: false the client is trusted to honour, it hints the gate is enforced in the browser, which means a user can flip it. Each of these is the same root cause wearing a different hat: the response says more than the caller was entitled to hear.

The fix is authorization on the endpoint, and a serializer that says less

There is no header to add and no config flag to flip. The fix lives in the endpoint: it has to authenticate the caller and then return only the records and fields that caller is allowed to see.

// returns every user, every column
app.get("/api/users", async (req, res) => {
const users = await db.user.findMany();
res.json(users); // includes role, is_admin, internal notes
});
Authenticate the request, scope the query to the caller, and serialize only the fields the UI renders.

Two moves. First, scope: every endpoint that returns data needs to know who is asking and filter to what they own, server-side, on every request. Never rely on the front-end to request only its own slice, because the front-end is a suggestion an attacker can ignore. Second, trim: return the specific fields the client renders, not the whole row. Keep role, is_admin, internal notes, and entitlement decisions on the server, where the client can read the outcome but never the switch.

Whether one of your endpoints hands back other people's data is something an attacker finds just by watching what your own page requests, so that is exactly how SurfaceCheckr looks for it. During a real-browser render it observes the xhr and fetch responses the page fires itself, and flags the ones that carry many people's personal data, that over-share sensitive fields like role or is_admin, or that return data from an admin-named endpoint with no visible auth. It is strictly passive: it reads the responses your page already requested, and it never crafts, replays, or pages through a request of its own. The evidence it keeps is redacted to a snippet, so the finding proves the exposure without archiving anyone's data. The leak is in the JSON your visitors can already see. The two-minute check is confirming it isn't more than they should.

Find it before someone else does.

Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.