ASVS 5.0 V8.2.3 requires that field-level access is restricted to consumers with explicit permission for specific fields. It is a Level 2 requirement. In plain words: being allowed to open an object does not mean being allowed to see or change every property on it. This is the control that stops broken object property level authorization, or BOPLA.
Why it matters
Most authorization code answers one question: can this user touch this record? Once the answer is yes, the API serializes the whole row. That is where the leaks happen.
Two failures show up again and again. The first is excessive data exposure: GET /users/42 returns role, internal_notes, password_reset_token and a salary field because the ORM model has them and the serializer does not filter. The second is mass assignment: PATCH /users/42 accepts {"role": "admin"} because the handler copies every key from the request body into the model.
Both are property level problems, and both pass an object level check without trouble.
How to implement it
Do not trust the model to decide what leaves the server. Define the readable and writable fields per role, explicitly, in one place, and default to nothing.
// field-policy.ts
type Role = "self" | "support" | "admin";
const READ: Record<Role, readonly string[]> = {
self: ["id", "name", "email", "avatarUrl"],
support: ["id", "name", "email", "plan", "createdAt"],
admin: ["id", "name", "email", "plan", "createdAt", "role", "status"],
};
const WRITE: Record<Role, readonly string[]> = {
self: ["name", "avatarUrl"],
support: [],
admin: ["name", "plan", "role", "status"],
};
export function pick(obj: Record<string, unknown>, allowed: readonly string[]) {
const out: Record<string, unknown> = {};
for (const key of allowed) if (key in obj) out[key] = obj[key];
return out;
}
export function readView(user: Record<string, unknown>, role: Role) {
return pick(user, READ[role]);
}
export function writeInput(body: Record<string, unknown>, role: Role) {
const allowed = new Set(WRITE[role]);
const rejected = Object.keys(body).filter((k) => !allowed.has(k));
if (rejected.length) {
const err = new Error("Forbidden fields: " + rejected.join(", "));
(err as any).status = 403;
throw err;
}
return pick(body, WRITE[role]);
}
Three choices matter here. The lists are allowlists, so a new column is invisible until someone adds it on purpose. Writes with disallowed fields are rejected, not silently dropped, so a client bug does not look like success. And the role comes from the authenticated session on the server, never from the request.
If you use GraphQL, the same rule applies per field resolver. Attach the check to the field, not only to the query that returns the parent type. Nested objects reached through relations need their own policy too.
How to test it
Build a small matrix test and run it in CI.
- Create one account per role plus a second ordinary user.
- For each role, fetch the same object and assert the exact set of keys returned. Compare against the policy, not against "no password field".
- For each role, send a PATCH containing one forbidden field at a time (
role,status,plan,ownerId,isAdmin). Expect 403 and check the database value did not change. - Send the forbidden field nested (
{"profile": {"role": "admin"}}) and as a differently cased key. - Repeat on list endpoints, search endpoints, export endpoints and webhook payloads, because those often use a different serializer.
A free pass over your API surface with the API security scan demo shows how unauthenticated and over-permissive responses get flagged.
Common mistakes
- Filtering in the frontend. The browser hides the field, the JSON still contains it.
- Blocklists. Removing
passwordandtokentoday missesmfa_secretnext quarter. - Checking fields on the main endpoint but not on
/export,/searchor the GraphQL schema. - Binding request bodies straight to ORM models (
Object.assign(user, req.body)). - Using the same DTO for create, update and read.
- Letting the client pick the view with
?fields=or?expand=without checking each requested field against the policy.
Try it yourself
Click through it at your own pace, or open it full screen.
Quick check
A user can open their own profile. Why is that not enough for V8.2.3?
Object level access says who may reach the record. V8.2.3 asks which fields of that record each consumer may read or write. A user may reach their profile but must not be able to read internal notes or set their own role.
Should a write with a forbidden field be ignored or rejected?
Rejected with a 403 is safer. Silent dropping hides client bugs and makes tests harder to trust.