Why Your API JSON Responses Are a Security Blind Spot
Learn how excessive JSON response fields expose sensitive data, how this differs from request-side mass assignment, and how to test explicit response contracts.
Primary source: authoritative reference
If an endpoint serializes a database object as it is, the response contains every attribute on that object, whether or not the client needs it. Authorization has to cover the properties in the response as well as the object.
OWASP names this risk API3:2023 Broken Object Property Level Authorization. The 2023 edition merged two 2019 entries into it: API3:2019 Excessive Data Exposure (reading properties the caller should not see) and API6:2019 Mass Assignment (writing them). This article covers the read side and shows how to test for it.
What Over-Exposure Looks Like
OWASP's first example is a dating app whose "report user" call returns the reported user's fullName and recentLocation, although the reporter should never see them. The fields were part of the object, so they were part of the response.
The script below reproduces the pattern with a synthetic User. Serializing the whole object (asdict(user)) returns the password hash, e-mail address, admin flag and internal notes alongside the name. Mass assignment is the write-side counterpart: the API maps request JSON onto a model and lets the client set a protected field such as is_admin.
Response Filtering
OWASP's prevention advice is to avoid generic methods such as to_json() and to_string() and to cherry-pick the properties you intend to return. Define the response for each endpoint explicitly, and add ?fields= support only through an allow-list.
from dataclasses import dataclass, asdict
import jsonschema
@dataclass
class User:
id: int
public_id: str
name: str
avatar_url: str
email: str
password_hash: str
is_admin: bool
internal_notes: str
user = User(7, "u_4f2a", "Ada", "https://cdn.example.test/ada.png",
"ada@example.test", "$2b$12$synthetic", False, "synthetic note")
# 1. Unconstrained serialization: every attribute becomes part of the response
leaky = asdict(user)
# 2. Explicit response model: only what this endpoint is meant to return
def public_user(u: User) -> dict:
return {"id": u.public_id, "name": u.name, "avatar": u.avatar_url}
# 3. Allow-list for ?fields=name,avatar
ALLOWED = {"name", "avatar", "id"}
def select_fields(payload: dict, fields_param: str) -> dict:
requested = {f for f in fields_param.split(",") if f}
return {k: v for k, v in payload.items() if k in (requested & ALLOWED)}
# 4. Closed schema for tests
user_schema = {
"type": "object",
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"avatar": {"type": "string"},
},
"required": ["id", "name"],
"additionalProperties": False,
}
def check(response: dict) -> str:
try:
jsonschema.validate(response, user_schema)
return "ok"
except jsonschema.ValidationError as e:
return "FAIL: " + e.message
select_fields intersects what the client asks for with what the endpoint allows, so a request for email or password_hash is silently dropped. Whether a field is allowed should also depend on who is asking; a static set like ALLOWED fits only fields that are public for every caller.
Testing the Response Contract
OWASP also recommends schema-based response validation as an extra layer. With additionalProperties set to False, a response that grows a new field fails the test instead of shipping. The checks below run against the definitions above:
print("leaky keys :", sorted(leaky))
print("public keys :", sorted(public_user(user)))
print("?fields=name,email,password_hash ->", select_fields(public_user(user), "name,email,password_hash"))
print("schema(leaky) :", check(leaky))
print("schema(public) :", check(public_user(user)))
leaky keys : ['avatar_url', 'email', 'id', 'internal_notes', 'is_admin', 'name', 'password_hash', 'public_id']
public keys : ['avatar', 'id', 'name']
?fields=name,email,password_hash -> {'name': 'Ada'}
schema(leaky) : FAIL: Additional properties are not allowed ('avatar_url', 'email', 'internal_notes', 'is_admin', 'password_hash', 'public_id' were unexpected)
schema(public) : ok
That output comes from running the full script with jsonschema 4.26 on Python 3.12. In a real suite, call the endpoint with a test client and pass response.json() to jsonschema.validate, for each role and for list, nested and error responses. A schema in a test does not stop a leak in production; it only catches the regression before release.
Inspecting Responses Locally
Sensitive JSON should not be pasted into online formatters. Use a tool that runs in the browser tab.
Format JSON Without Data Leaks
Stop pasting sensitive API responses into online formatters. Our client-side JSON tool handles your data locally, with validation and error highlighting.
Open JSON Formatter →Checklist
- Return an explicit model per endpoint (
public_userabove) instead of the persistence object. - Check the caller's right to each property, not only to the object. A role check on the route does not tell you which fields a given role may read.
- Close the schema with
additionalProperties: falsein tests, so a new database column cannot reach a response unnoticed. - Test every response shape: list endpoints, nested relations, error bodies, and each role that can call the endpoint.
- Keep full response bodies out of logs and analytics. Log the route, status code and size.