Authorization

Protect object properties

Allowlist fields callers may read or change so mass assignment and excessive data exposure cannot cross property-level permissions.

Being allowed to touch an object doesn’t mean being allowed to touch every field on it. Object-level authorization asks “may you access this invoice?”. Property-level authorization asks a second question: “which fields of it?”.

A customer can rename their invoice. They can’t mark it as paid. Same object, different fields, different rules.

Allowlist input fields

The dangerous pattern is spreading the request body straight into a database update. Compare:

// mass assignment: everything in the body reaches the database
await db.invoices.update(id, req.body)

// allowlist: only fields this operation may change
const { label } = req.body
await db.invoices.update(id, { label })

The first version is a mass assignment vulnerability. Whatever the client sends, the database receives. A customer may only change label, but one crafted request also flips isPaid and accountId.

Here’s what that request looks like, and what a protected API answers:

curl -i -X PATCH https://api.example.com/invoices/inv_9f3c2a \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label":"office chairs","isPaid":true,"accountId":"acc_admin"}'
# HTTP/1.1 400 Bad Request
# {"error":"fields not allowed: isPaid, accountId"}

You have two options for forbidden fields. Ignore them silently, which keeps old clients working. Or reject the request, which makes client mistakes visible right away. I prefer rejecting. Whatever you choose, pick one behavior and write it into the contract.

Allowlist output fields

The read side leaks in the same way. Build response objects from an approved list of fields, and use role-specific views when roles differ. A customer’s view of an invoice never includes internalNote. A support view might, because support staff hold a different permission set over the same object.

ORMs make both leaks easy. Calling toJSON() on a model serializes every column. Generated API clients happily send every field the schema exposes. Convenient, and exactly the problem.

Verify the stored row, not the status code

Here’s the part most people skip. The evidence you want is the stored row and the returned object, not the status code. A 400 can hide a partial write.

Run the malicious PATCH above, then check the database:

SELECT label, is_paid, account_id
FROM invoices WHERE id = 'inv_9f3c2a';
-- label changed; is_paid and account_id untouched

If you see a 400 response and a flipped is_paid column, validation ran after the write. The status code alone would never have shown you that bug. This is why I always look at the row.

Try this on your own API: send one update with one allowed field and two protected fields. Prove the allowed value changed, the protected values did not, and the response never reveals internalNote.

Lesson completed