Mass assignment, explained
One extra JSON field turns a profile update into an admin promotion. The GitHub incident that named the bug, and the allowlist patterns that close it for good.
- security
- vibe coding
- engineering
Mass assignment is what happens when your code takes a request body and hands
the whole thing to something that writes to a database. The request was supposed
to contain a display name. It also contained "role": "admin", and the ORM,
being helpful, wrote that too.
It is CWE-915, and in the API world it was API6:2019 Mass Assignment, folded in the 2023 edition into API3:2023 Broken Object Property Level Authorization. That rename is worth noticing: it says the real defect is not the assignment, it is that you authorized the row without authorizing the columns.
The incident that named it
In March 2012 Egor Homakov demonstrated the bug against GitHub itself by
adding his own public key to the rails/rails repository, giving himself commit
access to Rails. He had done it to make a point after his report about the
underlying Rails default was dismissed. GitHub’s writeup,
Public Key Security Vulnerability and Mitigation,
is still the clearest primary source.
The mechanism was that Rails’ update_attributes wrote any attribute present in
the params hash, and a public key record carried an owner id. Add
public_key[user_id] to the form post, and the key belonged to whoever you
chose.
Rails responded by making attribute allowlisting the default and, in Rails 4,
replacing the model-level attr_accessible with
strong parameters
at the controller: params are untrusted until explicitly permitted. Fourteen
years later every framework that was paying attention has an equivalent, and
every framework that was not still ships the 2012 default.
What it looks like now
The 2012 shape was a form post into an ActiveRecord model. The 2026 shape is JSON into an ORM, and it is one line in every stack.
Express with Mongoose:
// Vulnerable. Whatever the client sends, the client writes.
app.patch("/api/me", requireAuth, async (req, res) => {
const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
res.json(user);
});
PATCH /api/me with {"displayName":"pablo","role":"admin"} promotes the
caller. Note that authentication and even the object-level authorization are
both correct here: the user is logged in, and they are updating their own row.
That is precisely why this class survives code review. Nothing looks missing.
Prisma:
// Same bug, different ORM.
await prisma.user.update({ where: { id: session.userId }, data: req.body });
Django:
class ProfileForm(ModelForm):
class Meta:
model = Profile
fields = "__all__" # Vulnerable. Includes is_staff if the model has it.
FastAPI, where the Pydantic model is usually the defense and occasionally the hole:
# Vulnerable: model_dump() of a schema that inherited every model field.
user = await db.update(User, id=current_user.id, **payload.model_dump())
And the one specific to Supabase-backed apps, where the bug moves out of the application entirely. This policy is the one nearly every generated project ships:
-- Vulnerable. Correct at the row level, silent about columns.
create policy "users update own profile" on public.profiles
for update to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
With PostgREST in front of it, the client speaks to the table directly. There is
no controller to forget an allowlist in, so
supabase.from("profiles").update({ role: "admin" }).eq("id", myId) passes the
policy on both sides of the check. The row is theirs. The role column was
never supposed to be.
The fix: an allowlist, at the boundary
Blocklists lose here for the same reason they lose everywhere. A
delete req.body.role today does not know about the is_admin column somebody
adds next quarter, and the failure is silent. An allowlist that omits a new
column just ignores it.
Node, with a schema validator. The parsed output, not the original body, is what reaches the ORM:
import { z } from "zod";
// The list of what a user may change about themselves. The whole policy, in one place.
const SelfUpdate = z.object({
displayName: z.string().min(1).max(80),
avatarUrl: z.string().url().optional(),
}).strict(); // .strict() rejects unknown keys instead of dropping them
app.patch("/api/me", requireAuth, async (req, res) => {
const parsed = SelfUpdate.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid body" });
const user = await prisma.user.update({
where: { id: req.user.id },
data: parsed.data, // never req.body
});
res.json({ id: user.id, displayName: user.displayName });
});
.strict() is a deliberate choice over the default “strip unknown keys.”
Stripping is safe, but silent, and a client sending role is either a bug or an
attack, both of which you want to see in your logs.
Django. Name the fields:
class ProfileForm(ModelForm):
class Meta:
model = Profile
fields = ["display_name", "avatar"]
Django REST Framework. read_only_fields on the serializer, and remember
that a field absent from fields cannot be written at all, which is stronger.
Supabase and PostgREST. The right control is not in the policy, it is in the grant. Postgres has column-level privileges and PostgREST honors them:
-- Take back the blanket update, then hand back exactly two columns.
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;
Now the RLS policy decides which rows and the grant decides which
columns, which is the separation the OWASP rename was pointing at. An attempt
to write role fails with a permission error from Postgres itself, no
application code involved. If the privileged column has to change through a
supported path, expose that path as a security definer function that performs
its own check, rather than widening the grant.
The same reasoning applies to reads, and it is easy to forget: a select *
policy that returns every column of a row the user may see will happily return
stripe_customer_id and internal_notes. Column grants apply to select too.
Where it leads
Mass assignment is rarely the whole attack. It is the step that turns an ordinary account into a useful one.
- Writing
role,is_admin, ortenant_idis privilege escalation, which is the vertical sibling of IDOR. - Writing a foreign key (
user_id,organization_id) moves a record between tenants, which is the GitHub case exactly and is the worst outcome in any multi-tenant product. - Writing
email_verified,plan,credits, orpriceskips whatever process was supposed to set them. - Writing a timestamp or a status column corrupts the audit trail that would have told you it happened.
All four are instances of the same parent class, broken access control, which is number one on the OWASP Top 10 for the reason this post illustrates: the code contains no obviously missing check. The check that is missing was never written down as a check in the first place.
Why scanners find this one only sometimes
Our SAST engine, OpenGrep (app/engines/opengrep.py), matches the recognizable
shapes: req.body reaching an ORM write, fields = "__all__", a spread of a
request payload into a model constructor. Those patterns are worth catching and
they are the easy half.
The hard half is the Supabase case, because there is no vulnerable line of code anywhere. The application is a static frontend and a policy file, and the policy is correct about rows. Finding it means reading the schema and asking which columns a client may write, which is a question about intent. That is why our release decision treats the config and database layer as its own scan layer rather than a footnote to the code layer, and it is a good general reminder: the absence of a finding in a code scan is not evidence that a data model is safe.
Decide which fields a caller may write, write that list down somewhere the computer can enforce, and pass the parsed result forward. The body a client sent you is evidence, not instructions.