API · 9 min · Updated 22 Sep 2026
Your API returns more than your UI shows: how to check
The UI is not the boundary. Here is how to read what your endpoints really send, and why hiding a field in the front end changes nothing.
Your profile page shows a display name and an avatar. Two fields. The request behind it returned nineteen, and the other seventeen included an email address, a phone number, a stripe_customer_id, an is_admin flag and the hash of a password.
Nothing on the screen is wrong. The component reads the two fields it was asked to render and ignores the rest. But the rest arrived in the browser, sat in the response body, and is visible to anyone who opens the network tab — which on a public site means anyone at all.
The user interface is not a security boundary. It is a rendering of data that has already been sent. Every control that decides who may see what has to live on the server, in the query, before the response is written — because by the time a component chooses not to display a field, the field has already been delivered.
Why a coding agent writes it this way
This one is not carelessness, it is the path of least resistance, and it is worth understanding because it tells you which endpoints to check first.
You ask for a profile page. The agent needs the user’s row, so it writes the shortest query that returns a row — select("*"), or findUnique with no field selection, or SELECT * FROM users WHERE id = $1. Then it sends that object as JSON and renders two fields from it in the component.
The page looks exactly right. Every test passes. The feature is done by any measure the agent can see, and no part of the loop raises an objection, because from the outside a response with two useful fields and a response with nineteen are indistinguishable.
A wide response is a moderate problem on your own record and a severe one the moment the endpoint also accepts somebody else’s identifier. One returns more of your own data than it should; the other hands over a complete foreign record in a single request. Check both questions on the same endpoint, in that order.
Read what your API actually returns
This takes about five minutes per screen and needs no tooling beyond the browser you already have open. The discipline is to read the response body instead of the rendered page.
- 01Open your app and sign in as a normal account. Open developer tools, go to the Network tab, and filter to
Fetch/XHR. - 02Click through your own screens — profile, settings, dashboard, any list of records, anything with a detail view.
- 03For each request, open the Response tab and read the JSON. Not the preview of it; the body.
- 04For anything shaped like a record, list the keys and compare them against the fields the screen actually uses.
- 05Write down every key the UI does not need. Each one is either deliberate or a finding, and you now have to decide which.
Then check the endpoint outside your own front end, because your front end may be sending headers and parameters that flatter it. Request it directly and read the whole body.
# your own session, your own record — read every key that comes back curl -s "https://yourapp.com/api/me" \ -H "Cookie: <your session cookie>" | jq 'keys' # the same for a list endpoint: what does ONE element carry? curl -s "https://yourapp.com/api/projects" \ -H "Cookie: <your session cookie>" | jq '.[0] | keys'
The field names worth reacting to immediately: anything containing password, hash, secret, token, key, email, phone, address, stripe_, is_admin, role or internal_. A password hash in a response is an emergency; an internal role flag is an invitation to try changing it.
Fix it in the query, not the component
There is one correct place for this fix and it is the line that fetches the data. Filtering in the component, deleting keys before rendering, or hiding fields with CSS all leave the payload untouched.
// Supabase — before
const { data } = await supabase.from("profiles").select("*").eq("id", id);
// after
const { data } = await supabase
.from("profiles")
.select("id, display_name, avatar_url")
.eq("id", id);
// Prisma — before
const user = await prisma.user.findUnique({ where: { id } });
// after
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, displayName: true, avatarUrl: true },
});
// raw SQL — before
SELECT * FROM users WHERE id = $1;
// after
SELECT id, display_name, avatar_url FROM users WHERE id = $1;If reshaping every query is too large a change today, put one serialiser between your records and your responses and route everything through it. An allowlist there fails closed: a column added to the table later does not silently appear in the API.
function publicProfile(row) {
return {
id: row.id,
display_name: row.display_name,
avatar_url: row.avatar_url,
};
}
// every handler returns publicProfile(row), never rowRe-request the endpoint and read the body again. The field is fixed when it is absent from the response, not when it is absent from the screen. If you asked a coding agent to make this change, this check is the only thing that tells you whether it changed the query or just the component.
The five endpoints to check first
If you only have twenty minutes, spend them here. These are the endpoints where we find wide responses most often in apps built with coding agents.
- 01
/api/me,/api/profileor whatever loads the signed-in user. Almost always the whole row. - 02Any list endpoint —
/api/projects,/api/orders. A wide response is multiplied by every element in the array. - 03Search and autocomplete. Built quickly, rarely reviewed, and frequently returns full user records to match on a name.
- 04Anything with a member or team list, where other people’s email addresses are the usual find.
- 05Webhook and integration callbacks, which tend to hold the identifiers of your payment provider and your own internal flags.
Short answers, no hedging
- Why does my API return fields my UI does not display?
- Because most endpoints are written to return a record, not a view of a record. An agent asked to build a profile page writes a query that selects the row and sends it, then picks the two fields it needs in the component. Everything else in that row travels to the browser and stays in the response, where the network tab shows it in full.
- Is it a real vulnerability if the extra fields are not shown on screen?
- Yes, when those fields are data the caller should not hold. This is excessive data exposure, and it is in the OWASP API Security Top 10 for a reason. Anyone can read a response body: it takes one browser tab and no tooling. Hiding a field in the component removes it from the page, never from the payload.
- How do I see what my API actually returns?
- Open your app, open the network tab, filter to fetch or XHR, click through your own screens and read each response body rather than the rendered page. For anything that looks like a record, compare the keys in the JSON against the fields your UI uses. Every extra key is either deliberate or a finding.
- What is the difference between this and an IDOR?
- Excessive data exposure is about how much a legitimate request returns; an IDOR is about being able to make a request for somebody else's record at all. They compound: an endpoint that returns every column becomes far more serious the moment another account's identifier is accepted, because one request then yields a complete foreign record instead of a fragment.
- How do I fix over-posting without breaking my front end?
- Select explicitly instead of filtering after the fact. Name the columns your query returns, or map the record through an allowlist before serialising, so adding a column to a table can never widen a response. Then re-request the endpoint and read the body again to confirm the field is gone from the payload, not merely from the screen.
Stop reading about it. Point us at your URL.
The free Preflight is passive and read-only. It checks your app for exactly the exposures this guide describes and returns a score, a verdict and one finding with its evidence. No signup, no card.