The common mistake is treating the client as a trusted filter. Most APIs bind incoming request parameters directly to internal objects or return entire database records to the client, assuming the frontend will only send or display the fields a user should access. When I update my profile and send {"email": "new@example.com", "role": "admin"}, the server often accepts both fields if the underlying user object has a role property. When I fetch my account details, the response includes my hashed password, internal timestamps, and soft delete flags because the endpoint serializes the whole model.
This is broken object property level authorization. The object exists and belongs to me, so object level authorization passes. But I should not be able to write to certain properties or read others. The framework makes it trivial to bind all fields or return all columns, and developers rarely add a second layer of property level control.
Why It Hides
The frontend never sends the dangerous fields or displays the sensitive ones, so normal usage looks fine. Code review sees a standard update handler or a typical serializer. There is no obvious flaw in the logic because the logic is missing entirely. The framework does the work, and the absence of an allowlist is invisible. Automated scanners see a 200 response and move on. The vulnerability only surfaces when someone inspects the request in a proxy and adds a field the UI does not include, or when someone reads the full response body instead of trusting what the page renders.
The Method
- Identify an endpoint where I can update a resource I own, such as PATCH
/api/users/meor PUT/api/profile. Capture the legitimate request. - Retrieve the same resource with a GET request and note every field in the response, especially those not editable in the UI: role, account type, credit balance, is_verified, created_at, internal IDs.
- Add each of those fields to the update request one at a time with a value I want but should not be able to set. Send the modified request and check if the server accepts and persists the change.
- Test write operations with different HTTP verbs on the same resource. POST, PUT, and PATCH sometimes have different parameter binding or validation logic.
- For read operations, examine the full response body from any endpoint that returns user data, invoices, or records. Look for fields that should be internal only: password hashes, tokens, soft delete flags, fields from joined tables, or properties that leak other users' information.
- Check if filtering or field selection is supported, such as
?fields=id,email,roleor GraphQL field requests. Test if I can request fields the UI does not normally display. - Repeat the process on resources I do not own but can access through a shared context, such as updating a team member's profile or reading another user's invoice in a multi-tenant system. Property level flaws often bypass object level checks.
The Deeper Nuance
Defense Pattern
I defend against this by defining explicit schemas for every request and response. On the backend, I use an allowlist of fields the client can write for each role and endpoint. I never bind request bodies directly to models. On read, I use a serializer or DTO that includes only the fields the client needs for that specific operation. I do not return the database row.
// Allowlist pattern for update
const userUpdateSchema = {
email: { type: 'string', writable: true },
name: { type: 'string', writable: true },
role: { type: 'string', writable: false },
balance: { type: 'number', writable: false }
};
function updateUser(req, user) {
const allowed = Object.keys(userUpdateSchema)
.filter(k => userUpdateSchema[k].writable);
const updates = {};
allowed.forEach(field => {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
});
return db.users.update(user.id, updates);
}
Why It Stays a Problem
Frameworks optimize for developer speed. Binding all parameters or serializing entire models is one line of code. Adding allowlists and response shaping is manual work on every endpoint. Teams ship features and assume the client will behave. There is no compiler error when you forget the allowlist, and the feature works perfectly in testing because the test uses the UI. By the time someone notices, the pattern is everywhere.
Property level authorization requires treating the client as an adversary on every field, not just every object. I test by sending what I should not and reading what should be hidden.