Mass assignment: test APIs for hidden writable fields

Mass assignment happens when an API takes the fields a client sends and writes them straight into a database record. It is one of the quietest bugs in web apps. Nothing crashes. Nothing looks wrong in the logs. A user just ends up able to change a field you never meant them to touch, like a role, a price, or an account owner. This post shows you how to test for mass assignment and prevent it by design, with a worked example in Node and Postgres and a regression test you can add to your suite this week.

What mass assignment is and why it keeps happening

Most web frameworks make it easy to turn a request body into an object and save it. That convenience is the problem. If your handler does something like db.users.update(id, req.body), the database will accept every column name the request contains. Your form might only show name and email, but the API does not know or care what your form shows.

The OWASP Mass Assignment Cheat Sheet describes it as a framework binding request parameters straight onto objects, so an attacker can add a parameter the developer never meant to accept. Its example is a sign-up form plus one extra field, isAdmin=true. Depending on the framework it is also called autobinding or object injection. OWASP adds that field names can be guessed, so nobody needs your source code. Checking that a user may edit a record is not enough; you also have to check which fields of it they may edit.

Diagram of an extra isAdmin field added to a request and saved by the framework, with the fix and the test

One extra field, and how to fix and test it. Simplified from the OWASP Mass Assignment Cheat Sheet.

It keeps happening for three ordinary reasons:

  • Schemas grow. A table starts with safe columns. Six months later someone adds is_admin or credit_balance, and the old update handler now writes those too.
  • ORMs hide the column list. When you never type the field names, you never review them.
  • Frontends feel like a boundary. They are not. The browser is a suggestion. The server is the rule.

How to prevent mass assignment: four design rules

You do not need a special library. You need a habit: the server decides which fields are writable, per action, per role. These four rules cover most cases.

1. Allowlist writable fields for every endpoint

Never pass the raw body to a write. Pick the fields you expect and drop the rest. A blocklist (remove is_admin) fails the day someone adds a new sensitive column. An allowlist fails safe: new columns stay unwritable until you add them on purpose. OWASP recommends allow-lists over block-lists for exactly this reason, and reminds you that neither replaces the check that this user may change this record at all.

2. Use input types that are separate from your database models

Define a small input shape for each action, often called a DTO or a request schema. UpdateProfileInput has name and email. It does not have role, because profile edits never change roles. Your database model can have forty columns. Your input type should have only what the action needs.

3. Make server-owned fields read-only in code

Some fields should only ever be set by the server: id, owner_id, created_at, role, plan, balance, email_verified. Set them from the session or from business logic, never from the request. If a client sends them, ignore them or reject the request.

4. Reject unknown fields instead of silently dropping them

Silently stripping extra fields is safe. Rejecting them with a 400 is safer and more useful. It tells your own frontend team when they send something wrong, and it makes probing attempts show up in your error logs where you can see them.

A worked example in Node and Postgres

Say you have a users table with id, name, email, role, plan and created_at. Users can edit their own profile. Admins can change roles. Billing changes the plan after a payment succeeds.

The risky version looks like this in plain words: the route reads the session user, reads the body, and runs UPDATE users SET ... built from every key in the body. It works perfectly in every demo, and it lets anyone set their own role.

The safer version has three small parts.

First, a strict schema for the action. Using a validation library such as Zod, you define UpdateProfileInput as an object with an optional name string and an optional email string, and you mark the object as strict so unknown keys fail validation. In Zod that is z.object({ name: z.string().max(100).optional(), email: z.string().email().optional() }).strict().

Second, a handler that only uses parsed values. The route calls UpdateProfileInput.parse(req.body). If parsing fails, it returns 400. If it passes, the handler builds the update only from the parsed object, and it takes the user id from the session, not from the URL or body.

Third, a parameterized query with a fixed column list. Something like UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email) WHERE id = $3. The column names are written in the code. No request can add a new one.

The role change gets its own route, its own schema with only a role field, and an admin check before anything else runs. The plan change has no public route at all. It happens inside your payment webhook handler after the payment provider confirms the charge.

Notice the pattern. Each action has its own door, and each door only opens onto the fields that action needs.

Add a regression test so it stays fixed

The fix above protects you today. A test protects you after the next refactor. Write it against your own API in your own test environment.

The test is short. Sign in as a normal test user. Send a profile update that includes a legitimate field, like a new name, plus fields that user should never write, like role and plan. Then assert two things:

  1. The response is a 400 (if you reject unknown fields) or a 200 that ignores them (if you strip).
  2. Reading the user back from the database shows role and plan unchanged.

The second assertion matters most. A response code can lie. The database row cannot. Add one of these tests for every write endpoint that touches a table with sensitive columns. When someone adds a column later, extend the test with it as part of the same pull request.

A few related checks belong in the same review:

  • Nested objects. If an endpoint accepts { profile: { ... } }, the nested schema needs the same strictness.
  • Bulk and import endpoints. CSV imports and batch updates often skip the schemas the single-record routes use.
  • Response fields. The mirror problem is returning too much. Use output types too, so password_hash or internal notes never leave the server.

Mass assignment often sits next to other trust problems. If your forms and APIs accept writes from a signed-in browser, also read our guide on CSRF protection for forms and APIs. And if you are wiring AI agents to your API, the same allowlist thinking applies to what tools an agent can call and what data it can send out, which we cover in AI agent data exfiltration.

Where a second set of eyes helps

Your own tests check the fields you thought of. The fields you forgot are the ones that hurt.

whitehatstoic's Cybersecurity and AI safety testing card listing web app and API review, prompt injection tests and retest after fixes

whitehatstoic runs security tests on web apps and AI systems, including web app and API review and prompt injection tests. You get a written report with fixes, and a retest after you apply them. Auditing sign-in and payments is one of the things listed on the whitehatstoic home page. No test can promise to find every weakness.

If you are still building, the cheaper path is to get it right from the start. whitehatstoic builds web and mobile apps from database to deploy with Next.js, Node and Postgres, or Expo and React Native, including accounts, payments and admin. The work is tested, monitored and documented so your team can keep building on it. That is the idea behind "Hire the builder who also tests it."

Frequently asked questions

Is mass assignment only a problem with ORMs?

No. ORMs make it easier to fall into, but any code that builds a write from client-supplied keys has the same risk. Hand-written SQL that loops over request keys to build a SET clause is just as exposed.

Should I strip unknown fields or reject them?

Both are safe if done on every write path. Rejecting with a 400 is usually better, because it catches frontend bugs early and makes probing visible in your logs.

Does input validation alone prevent mass assignment?

Only if the validation is an allowlist tied to each action. Checking that email looks like an email does nothing if the same request can also set role.

How does whitehatstoic price an API security review?

There are no published prices. Security testing is scoped after a short call, and you get a custom quote based on what needs testing.

Get started

Pick one write endpoint today. Replace the raw body with a strict schema, fix the column list in the query, and add the regression test that reads the row back. Then do the next one.

If you want an outside review of your API, you can book a security testing call directly. If you are starting a new product, book a full-stack development call instead.

Building something that has to be safe? Book a meeting with whitehatstoic: tell us the product, the deadline and your biggest worry, and we reply with a plan and a price.

0 likes

Comments

No comments yet.

Sign in or make an account to comment.