Users can edit fields they shouldn't: fixing mass assignment in Prisma and Supabase
By Obscura Security · Updated · Prisma, Supabase, Next.js, Express, Cursor, Lovable
Short answer
If an update handler passes the whole request body to the database (prisma.user.update({ data: req.body }), supabase.from('profiles').update(body)), users can add fields you never meant them to change, like role, credits or plan. Pick the allowed fields explicitly, and in Supabase restrict which columns users can write.
Mass assignment is a bug where your server saves whatever the request contains. Your profile form has two fields, name and bio. The request a user can send by hand has as many fields as they like.
Why this happens
Update handlers written quickly, or by an AI tool, often do the shortest possible thing:
// Next.js route handler
export async function PATCH(req: Request) {
const user = await getCurrentUser()
const body = await req.json()
const updated = await prisma.user.update({
where: { id: user.id },
data: body, // whatever the client sent
})
return Response.json(updated)
}
The form sends { "name": "Sam" }. A person using the browser’s developer tools or curl sends { "name": "Sam", "role": "admin", "credits": 100000, "plan": "pro" }, and every one of those fields is written.
The same thing happens in Express with req.body, and in Supabase when the browser calls the database directly:
await supabase.from('profiles').update(formValues).eq('id', user.id)
If profiles has columns like role, is_verified, credits or plan, and the user is allowed to update their own row, they can write to those columns. Row Level Security checks which row, so it does not stop this. The sensitive columns just happen to live in a row the user is allowed to edit.
A related version affects inserts. A signup route that creates a user from the body lets people sign up as an admin, and a create-order route that copies the body lets them set status to paid, or owner_id to someone else’s ID.
How to check your own app
Use your own app with a normal test account, not an admin.
- Find the sensitive columns: anything about roles, plans, credits, balances, verification, ownership (
user_id,owner_id,org_id) or status. Your schema file (such asschema.prisma) or the Supabase Table Editor lists them. - Search your code for
data: req.body,data: body,.update(body),.update(req.body),...req.bodyandObject.assign(with a request object. Each hit is a suspect. - Test it. Open your app’s profile page with the Network tab open, save a change, and copy the request as
curl(right-click the request). Add one extra field to the JSON, such as"credits": 999, and send it with your own test account. Then look at the record in your database. If the extra field changed, the endpoint has the bug. - For a Supabase app, run
await supabase.from('profiles').update({ credits: 999 }).eq('id', myUserId)from the browser console while signed in as your test user, then check the row.
Reset the test data afterwards.
The fix
1. Choose the fields on the server
Validate the body with a schema that lists only the editable fields, then use the parsed result:
import { z } from 'zod'
const updateProfileSchema = z
.object({
name: z.string().trim().min(1).max(80),
bio: z.string().max(500).optional(),
})
.strict()
export async function PATCH(req: Request) {
const user = await getCurrentUser()
if (!user) return Response.json({ error: 'Unauthorised' }, { status: 401 })
const parsed = updateProfileSchema.safeParse(await req.json())
if (!parsed.success) {
return Response.json({ error: 'Invalid input' }, { status: 400 })
}
const updated = await prisma.user.update({
where: { id: user.id },
data: parsed.data, // only name and bio can be here
select: { id: true, name: true, bio: true },
})
return Response.json(updated)
}
Two details matter. The where uses the ID from the session, never an ID from the body. And the response uses select, so the whole row, including internal fields, is not sent back.
Build separate schemas for create and update, and for admin and user versions of the same action.
2. In Supabase, restrict the columns users can write
If your app updates tables from the browser, enforce the rule in the database. Column privileges decide which columns a role may write. Supabase grants broad access on public tables to the anon and authenticated roles by default, so remove the table-level grant first, then grant back only the safe columns:
revoke update on table public.profiles from anon, authenticated;
grant update (display_name, bio, avatar_url) on table public.profiles to authenticated;
Do the same for insert if users can create rows:
revoke insert on table public.profiles from anon, authenticated;
grant insert (id, display_name) on table public.profiles to authenticated;
After this, a request that touches role or credits fails with a permission error. Your server code, which uses the service role, can still change them. Tables created later get the broad grants again, so repeat this for each new table.
If you would rather not change grants, a trigger can do the same job:
create or replace function public.protect_profile_columns()
returns trigger
language plpgsql
as $$
begin
if current_user in ('anon', 'authenticated') and (
new.role is distinct from old.role
or new.credits is distinct from old.credits
or new.id is distinct from old.id
) then
raise exception 'These fields cannot be changed';
end if;
return new;
end;
$$;
create trigger protect_profile_columns
before update on public.profiles
for each row execute function public.protect_profile_columns();
3. Keep ownership checks in the policy
Make sure the update policy has a with check clause, so a user cannot hand a row to someone else by changing its owner ID:
create policy "Update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );
4. Move sensitive values out of user-editable tables
Roles, plan and credit balances are safer in their own table that has no insert or update policy for users, written only by your server or by a payment webhook. See admin page only hidden in the UI for a roles table you can copy.
Mistakes to avoid
- Spreading the body into the query.
{ ...req.body, updatedAt: new Date() }still carries every field the client sent. - Removing one bad field. A deny-list like
delete body.rolemisses the next sensitive column you add. List the allowed fields instead. - Taking the user ID from the body. Use the session on the server.
- Fixing the form and not the endpoint. Removing the field from the interface changes nothing for someone sending requests by hand.
Common questions
- What is mass assignment?
- It is when a server takes an object from the request and writes it to the database without choosing which fields are allowed. The user controls the field names as well as the values, so they can add fields the form never showed.
- Doesn't my form only show the fields I want users to edit?
- The form only controls what honest users send. Anyone can send a request by hand with extra fields in it, and your server will accept them if it passes the body straight through.
- Do Supabase RLS policies stop users editing a column?
- Not by themselves. RLS decides which rows a user can touch, not which columns. A user allowed to update their own profile row can update every column in it, including role, unless you restrict column privileges or add a trigger.
- Does zod protect me?
- Only if the schema lists just the editable fields. By default z.object() drops unknown keys, which is safe as long as you then use the parsed result and not the original body. Adding .strict() makes unknown keys an error, which shows probing attempts in your logs.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].