Some API bugs don't need a payload. No quotes, no angle brackets, no encoding tricks. Just one extra field in a JSON body that the developer never expected anyone to send:
{"name": "Alice", "role": "admin"}
That's mass assignment. The server takes whatever object you send and copies every field onto its data model, including the ones you were never supposed to touch. In the OWASP API Security Top 10 (2023) it sits inside API3: Broken Object Property Level Authorization, which merges two 2019 categories: Mass Assignment (API6:2019) and its twin, Excessive Data Exposure (API3:2019).
This guide covers what it is, why frameworks make it so easy to ship, how to find it with accounts you control, and the part that decides whether a report gets accepted: how to prove it cleanly.
One rule before we start: only test applications you own or are explicitly authorized to test. Every host below is a stand-in (app.example.com).
Prerequisites
Comfort with HTTP, JSON request bodies and an intercepting proxy like Burp Suite or Caido. If you've read my IDOR/BOLA guide, this is its sibling: BOLA asks "is this object yours?", mass assignment asks "is this **field* yours?"*
What is mass assignment?
Frameworks save developers time with automatic binding. A handler receives a request body and does something like user.update(request.body). Every key in the body becomes a property on the model.
That's fine while the client only sends name and email. The bug appears when the model also has properties like:
-
role,is_admin,permissions -
plan,credits,balance,trial_ends_at -
email_verified,account_status -
org_id,tenant_id,owner_id
The UI never shows those fields, so the developer forgets they're writable. The server never checks, because nobody told it to.
The classic lesson is from March 2012. Egor Homakov had reported mass-assignment defaults as a Rails issue, and the report was closed. On 4 March he used a mass-assignment flaw in GitHub's public key update form (GitHub runs on Rails) to attach his key to the rails organization and pushed a file to the project to show it worked. GitHub's own write-up traces the root cause to incoming form parameters that weren't properly checked; it deployed a fix about an hour later. The Rails world moved too: Rails 4.0 deprecated the old protection in favor of strong parameters, an explicit allowlist of the fields a request may set.
How it works
A profile update looks harmless:
PATCH https://app.example.com/api/v1/me {"name": "Alice"}
And a profile read echoes the full user object:
{"id": 4812, "name": "Alice", "email": "alice@example.com", "role": "user", "plan": "free", "email_verified": true}
That response just told you the model's property names. Now send one more:
PATCH https://app.example.com/api/v1/me {"name": "Alice", "role": "admin"}
If the server binds the whole body, role is now admin. No error. Often the response even confirms it.
Three things make this class so common:
- Responses leak the schema. Excessive data exposure, the other half of API3, hands you the field names.
- Create and update share code. The sign-up handler and the admin "edit user" handler often call the same model method.
- Hidden fields aren't secret fields. A field missing from the UI is still writable unless the server says otherwise.
How to find it
Use two accounts you control on a target you're authorized to test: one low-privilege, and ideally an admin or a second user so you can compare.
1. Map every write endpoint. Sign-up, profile update, settings, invite acceptance, checkout, "create project". Anything with POST, PUT or PATCH and a body.
2. Harvest field names. Collect every property you see in responses, the JavaScript bundle, OpenAPI or GraphQL schemas, and the admin UI if you have an admin test account. Fields the admin can set but a user can't are your best candidates.
3. Add one field at a time. Send the normal request plus a single extra property. One field per request keeps the evidence clean and tells you exactly which property is bindable.
4. Try the boring variants. is_admin: true, role: "admin", a nested {"user": {"role": "admin"}}, snake_case and camelCase spellings, and the same field on the create endpoint (sign-up) as well as update.
5. Re-read the state from a different request. A 200 on the write proves nothing. Fetch the object again with a fresh GET, or log in again, and check whether the property actually changed.
Steps 3 and 5 are a loop, so they're easy to script. Here's a small standard-library Python version for your own test account. I ran it against a local mock API before publishing; point it only at an app you're authorized to test:
import json
import urllib.request
BASE = "https://app.example.com/api/v1" # a target you're authorized to test
TOKEN = "Bearer <your-test-account-token>"
CANDIDATES = {"role": "admin", "is_admin": True, "plan": "enterprise", "email_verified": True}
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Authorization": TOKEN, "Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)
before = call("GET", "/me")
for field, value in CANDIDATES.items():
call("PATCH", "/me", {"name": before["name"], field: value}) # one extra field per request
after = call("GET", "/me") # independent re-read
if after.get(field) == value and before.get(field) != value:
print(f"[bound] {field}: {before.get(field)!r} -> {after.get(field)!r}")
else:
print(f"[ignored] {field}")
It changes your test account's state when a field binds, so note every [bound] line and put the values back afterwards (more on that below).
How to prove it (without breaking anything)
This is where most mass-assignment reports fall apart. "I sent role: admin and got 200" tends to get closed as informational, because plenty of APIs silently ignore unknown fields. Detection isn't proof.
A report that triages cleanly answers three questions.
1. Did the property really change? Show before and after from an independent read:
GET /api/v1/me -> "role": "user"
PATCH /api/v1/me {"role": "admin"} -> 200
GET /api/v1/me -> "role": "admin"
2. Does the change grant anything? A stored string isn't impact. Show the effect with your own test account: an admin-only endpoint that returned 403 before now returns 200, or a paid feature unlocks on a free account. Stop at the first proof. Don't explore the admin panel, read other users' data or spend credits you didn't pay for.
3. Can you undo it? Reset the property (or ask the program to) and say so in the report. A non-destructive finding is easier to accept and safer for you.
Then write the summary as a chain of evidence, not a label:
Low-privilege account
alice@example.comsentPATCH /api/v1/mewith an extrarolefield. A freshGETshowsrole: admin, andGET /api/v1/admin/users, which returned 403 before, now returns 200. Fields are bound without an allowlist. Reverted after testing.
That's the whole difference between "possible issue" and a finding someone has to act on: you proved it, with your own account, and left nothing behind.
How to fix it
For developers reading this, OWASP's prevention advice for API3 comes down to: don't bind client input straight to internal objects, allow updates only to the properties a client is meant to edit, and return only what each endpoint needs.
Allowlist writable fields per endpoint. In Rails, that's strong parameters:
def user_params
params.require(:user).permit(:name, :email)
end
In Express (or anything without a built-in), pick the editable keys yourself before the body gets near your data layer:
const EDITABLE = ["name", "email"];
const pick = (body, allowed) =>
Object.fromEntries(Object.entries(body).filter(([key]) => allowed.includes(key)));
// in the handler: users.update(req.user.id, pick(req.body, EDITABLE))
Separate input schemas from models. The object a client may send is not the object you store. DTOs, Pydantic models, serializers with explicit fields: whatever your stack calls it.
Return only what the client needs. Less schema in responses means less to guess, and it closes the excessive-data-exposure half of API3 too.
Test it. For every write endpoint, a test that sends a privileged field and asserts it was ignored. It's the same loop as the probe script above, pointed at your own CI.
Try it yourself
Only on targets you own or are explicitly authorized to test. These are built for it:
- PortSwigger Web Security Academy: the API testing topic has the lab "Exploiting a mass assignment vulnerability", where the goal is to buy an item you can't afford.
- OWASP crAPI: three mass-assignment challenges, including getting an item for free and raising your balance.
-
VAmPI: a small vulnerable REST API that lists mass assignment among its bugs, good for practicing with
curlor the script above.
Once you've got the loop working by hand, it's worth running in an automated setup that also re-tests every hit before calling it a finding. Xalgorix (disclosure: I build it) is one open-source option; its methodology covers IDOR and broken access control and re-exploits each candidate finding before it reports it.
Conclusion
Mass assignment survives because it's invisible in the UI and silent on the wire. The fix is an allowlist, and the test is one extra field. Find it by harvesting field names and adding them one at a time. Prove it with an independent re-read and a real privilege change, not a 200. Then put it back the way you found it.
Further reading
- OWASP API3:2023 Broken Object Property Level Authorization
- GitHub: Public Key Security Vulnerability and Mitigation (4 March 2012)
- LWN: Mass assignment and the GitHub incident (March 2012)
- Rails Guides: Strong Parameters and the Rails 4.0 release notes
- PortSwigger lab: Exploiting a mass assignment vulnerability
- OWASP crAPI challenges ยท VAmPI
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
๐ If this was useful, follow me for more hands-on web & API security write-ups.
๐ Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (0)