how to prevent mass assignment in APIs
How to prevent mass assignment (over-posting) in APIs: enumerating write endpoints, putting explicit field allowlists on each one, separating admin-only fields onto admin-only endpoints, probing with curl, and adding regression tests. Framework notes for Pydantic/FastAPI, Rails, Express, and GraphQL. Use when building or reviewing write endpoints. Triggers: 'mass assignment API', 'over-posting fix'. Not for: read-side auth bugs (IDOR), input validation.
how to prevent mass assignment in APIs
TL;DR
Never bind request data directly to your data model. Define exactly which fields each endpoint accepts, an explicit allowlist, and reject or ignore everything else. The classic bug is a user updating their profile and quietly setting their own role to admin because the framework copied every field. Allowlists at the boundary fix the whole class.
how to prevent mass assignment in APIsUse this when
- You are building or reviewing an API that writes user data
- A security review flagged "mass assignment" or "over-posting"
- Your framework auto-binds request bodies to models (Rails, Laravel, Django, Spring)
- You want a checklist to verify every write endpoint
Not for this skill when
- You need to fix broken access control on reads (that is IDOR, a different skill)
- You want input validation rules (validation is related but separate)
- You are auditing a third-party API you do not control (report it, do not probe aggressively)
- You need field-level encryption (different concern)
Steps
1. Find every endpoint that writes from request data
List your create and update endpoints and check how each one turns the request body into a model. Anywhere the framework copies the whole body onto the model is a candidate.
grep -rnE "Object.assign|permit!|update_attributes|model_dump()" src/ --include="*.py" --include="*.js" --include="*.rb" | head -20Expected: a list of write endpoints with the binding mechanism noted for each. If you cannot enumerate them, generate the route list from the framework or your OpenAPI spec and work through it.
2. Put an explicit allowlist on each write endpoint
Declare exactly which fields the endpoint accepts and drop everything else. The idiom differs by framework but the shape is the same: a schema that names the fields.
from pydantic import BaseModel
class ProfileUpdate(BaseModel):
name: str
bio: str = ""
# role is intentionally absent: not user-settable, everExpected: the schema contains only fields the caller may set. Sensitive fields like role or is_admin simply do not appear; there is no code path where user input can reach them. (Pydantic v2 with FastAPI; same idea as Rails strong params or DRF serializers with explicit fields.)
3. Separate admin-only fields onto admin-only endpoints
If admins legitimately set fields users cannot, those fields live on a separate admin endpoint with its own allowlist and its own authorization check. Never share one schema and rely on "the frontend does not send it".
grep -rnE "is_admin|role" src/schemas/ | head -20Expected: admin-only fields appear only in admin schemas, and the admin endpoint rejects non-admin callers before parsing input. One shared "update user" endpoint with conditional fields is how mass assignment sneaks back in.
4. Probe your own endpoints
Send the fields you should not be able to set and confirm they are ignored or rejected. Do this in a test environment against your own API.
curl -s -X PATCH https://example.com/api/accounts/42 -H "Content-Type: application/json" --request-json '{"name":"test","role":"admin"}' | python3 -c "import json,sys; print(json.load(sys.stdin).get('role'))"Expected: the printed role is unchanged (the old value, not "admin"). If the response shows role admin, you have a live mass assignment bug; fix the allowlist, then re-probe. Test create endpoints the same way.
5. Add a regression test per endpoint
Encode the probe as an automated test: post the forbidden field, assert it did not take effect. Run it in CI so the next developer who adds a field to the model does not silently widen the API.
printf 'test: PATCH /accounts/{id} with role=admin gives role unchanged\n' | tee -a api-security-tests.txtExpected: a test suite where each write endpoint has a "rejects unexpected fields" case, all green. The test fails loudly the day someone adds is_admin to the shared schema.
Variant: Rails strong parameters
Use params.require(:user).permit(:name, :bio) and never permit!. Audit for permit! and for nested attributes without a matching permit list; nested attributes are mass assignment's favorite hiding spot.
Variant: Express and plain JavaScript
There is no built-in protection; build the object yourself by picking from an allowlist array, or use a validation library that strips unknown fields. Copying the whole request body onto the model is the bug; treat it as a finding in every review.
Variant: GraphQL mutations
Allowlist the input type fields just like REST. GraphQL does not auto-bind, but resolvers that spread the input object into the model reintroduce the same bug; pick fields explicitly in the resolver.
Why this happens
Frameworks optimize for developer speed: binding a form or JSON body straight to a model saves boilerplate, so it became the default pattern and the tutorials teach it. The framework cannot know which fields are user-settable versus internal; only you know that role is not a profile field. Mass assignment is what happens when a convenience default meets a field the developer forgot the user could see. Explicit allowlists move that knowledge into code where it cannot be forgotten.
Edge cases and pitfalls
- Nested objects: the top-level schema can be clean while a nested settings object binds freely; allowlist at every level.
- PATCH semantics: partial updates still need the allowlist; "only sent fields update" does not mean "only safe fields update".
- Framework upgrades can change binding defaults; re-probe after major upgrades.
- Admin panels and internal tools often skip allowlists "because only staff use them"; staff accounts get phished too.
- Do not rely on the frontend to omit fields; the API is the boundary, and attackers do not use your frontend.
Provenance
Resolved from the public thread: https://vectle.com/posts/pstjYPA9KbqZ6fBsTvaFtPxw
Maintainer review
No maintainer verification is recorded for this version.
This records the version a maintainer checked. It does not assert that the version is the latest upstream release.