Your interface shows a user's name and avatar. Open the network panel and the response behind it contains their email, their phone number, their internal identifier, a permissions object, three boolean flags nobody outside engineering understands, and the timestamp of their last sign-in.
The interface filters. The API does not. Everything the endpoint returned is available to anyone who looks, which on the web is everyone.
This post is about responses that contain more than they display. For whoever serialises a model.
Why it happens
Not carelessness. It is the default behaviour of the most convenient tool.
An ORM model serialised directly returns every column. That is enormously convenient, it is what most frameworks make easiest, and it means your API contract is your database schema.
The consequence is that the contract changes whenever the schema does. A column added for an internal feature appears in the API response the moment it is added, with no code change and no review, to every consumer. Nobody decided to expose it; nobody decided anything.
Where the interesting leaks are
Nested objects. The endpoint returns a document, the document includes its author, and the author is serialised in full. The document was the thing being authorised; the author object came along for free.
Collections. A list endpoint returning fifty items with full serialisation exposes fifty times as much, and list endpoints get less scrutiny than detail endpoints.
Internal flags. is_internal_test_account, fraud_score, support_notes, experiment_bucket. These reveal how your system works and occasionally what you think about a specific customer.
Soft-deleted records included because the query did not filter them, or included with a deleted_at field that tells the caller something was removed.
Permission objects. A serialised role or permission set tells an attacker exactly what to aim for and what the privilege names are.
Other tenants' data in aggregates. A count, an average or a leaderboard computed across tenants, which is a small leak that is hard to notice and hard to undo once a customer has seen it.
The fix is an explicit response shape
Define what each endpoint returns, in code, separate from the model:
# not this
return jsonify(user.to_dict()) # every column, forever
# this
return jsonify({
"id": user.public_id,
"name": user.display_name,
"avatar_url": user.avatar_url,
})
The verbose version is the point. A new column does not appear in the response until somebody adds a line, which is the review you wanted.
Where your framework supports a serialiser or schema layer with explicit field declarations, use it, and turn off any mode that includes fields by default. The rule is allowlist, not blocklist, for the same reason it holds in logging: a blocklist protects the fields somebody remembered.
Vary the shape by audience
The same record often needs different shapes for different callers: a user viewing themselves, a colleague, an administrator, a third-party integration. That is not duplication to be factored away; it is the authorisation model expressed in the response.
Collapsing them into one shape with the most permissive field set, and filtering in the client, is the failure this post is about.
Check yours
# Read one response in full and compare with what the interface shows
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.example.com/documents/123 | jq 'keys'
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.example.com/documents/123 | jq '.author | keys'
# The list endpoint is usually worse
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.example.com/documents | jq '.items[0] | keys'
# Then: fetch something belonging to another user that you can legitimately
# see (a colleague's profile) and read every field returned.
The nested-object check is the one that finds things. Top-level responses get reviewed at some point; the object hanging off them rarely does.
The mobile consequence
On the web you can change the interface and the leak is closed for everyone immediately. On mobile the old client keeps calling the old shape, so if you narrow a response you break installed apps, and if you do not, the data keeps flowing.
Which is an argument for getting this right before you ship a client you cannot update, and for versioning responses so a narrowing is a new version rather than a breaking change to the current one.
The concession
Explicit shapes are more code and they drift from the model, which produces a real annoyance: adding a field means touching the serialiser as well as the model, and developers will experience that as friction with no visible benefit.
The honest answer is that the friction is the control, and it is cheap compared with the alternative. But it is worth choosing where to apply it: endpoints returning data about people, permissions or anything cross-tenant deserve explicit shapes. An internal endpoint returning a configuration object with no personal data can serialise the model and nobody is harmed.
The implication
The question is not whether your interface shows too much. It is whether your API returns more than your interface shows, and on most products the answer is yes by a wide margin.
Open the network panel on your own product and read one response properly. Everything in it is public to the person it was sent to.