SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Mass Assignment in Server Actions
CWE-915A01:2025 Broken Access ControlHigh severityNext.jsSupabase

Mass Assignment in Server Actions

◐Every Server Action the kit ships uses a closed Zod schema and writes named columns, and there is no privilege column to escalate into, but a new action that spreads the body would reopen it.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

Mass assignment, or over-posting, happens when a Server Action spreads the whole request body into a Supabase insert or update, so a user can set columns you never meant to expose, for example role, is_admin, credits, or user_id. The fix is to never pass client data through unfiltered: validate with a Zod schema that lists only the fields a user may change, then write those named fields explicitly, never a spread of the parsed object.

Where it shows up: A Server Action builds its update or insert from a spread of the request body, or from a schema that uses passthrough or a record type, instead of writing a fixed set of named columns.

The vulnerable patterns and their fixes

Spreading the request body into update()

✗Vulnerablets
// app/actions/profile.ts  (Server Action)
'use server'
import { z } from 'zod'

// permissive: passthrough keeps unknown keys instead of dropping them
const schema = z.object({ fullName: z.string() }).passthrough()

export async function updateProfile(data: unknown) {
  const input = schema.parse(data)
  const user = await getUser()
  if (!user) return { error: 'Unauthorized' }

  // every key the client sent is written, including role, credits, etc.
  await createAdminClient().from('profiles').update(input).eq('id', user.id)
}

The schema admits any extra keys, and the update writes the whole object. A request that includes role or credits sets those columns even though the form never showed them.

↓the fix
✓Securets
// app/actions/profile.ts  (Server Action)
'use server'
import { z } from 'zod'

// closed schema: unknown keys are stripped, not kept
const schema = z.object({ fullName: z.string().min(2) })

export async function updateProfile(data: unknown) {
  const { fullName } = schema.parse(data)
  const user = await getUser()
  if (!user) return { error: 'Unauthorized' }

  // only the named column is ever written
  await createAdminClient()
    .from('profiles')
    .update({ full_name: fullName, updated_at: new Date().toISOString() })
    .eq('id', user.id)
}

This mirrors the kit’s real updateProfile in actions/user.ts: a closed Zod object and an explicit column write. Extra keys are dropped by the parse, and the update can only ever touch full_name.

Over-posting on insert (forging the owner)

✗Vulnerablets
// app/actions/create-note.ts  (Server Action)
'use server'

export async function createNote(input: { title: string; user_id?: string }) {
  // user_id comes from the client and is trusted
  await createAdminClient().from('notes').insert({ ...input })
}

Spreading input lets the client set user_id, so an attacker creates a note owned by another user. The owner column should never come from the request.

↓the fix
✓Securets
// app/actions/create-note.ts  (Server Action)
'use server'
import { z } from 'zod'

const schema = z.object({ title: z.string().min(1).max(200) })

export async function createNote(data: unknown) {
  const { title } = schema.parse(data)
  const user = await getUser()
  if (!user) return { error: 'Unauthorized' }

  // owner is taken from the authenticated session, not the input
  await createAdminClient().from('notes').insert({ title, user_id: user.id })
}

The owner is sourced from getUser(), and the schema only permits title. There is no client-controlled path to set user_id, so a row can only be created for the caller.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

A profile form shows one field, full name, so it feels safe to take the form data and write it. But the form is client-side, and the request body that reaches the Server Action is whatever the attacker decides to send. They open dev tools, add role set to admin and credits set to a large number to the payload, and submit.

If the action spreads that body into an update, those extra columns are written alongside the one you intended. The schema you trusted does not help if it was permissive: a Zod object with passthrough, or a record type, keeps unknown keys instead of stripping them. The attacker just gained a column you never exposed.

Row Level Security does not save you here, for two reasons. The write goes through the service-role admin client, which bypasses RLS entirely. And even a user-scoped policy keyed on user_id only decides which rows the caller may touch, not which columns they may set, so it would happily let the owner of a row flip their own role to admin. On insert the same shape lets an attacker forge user_id and create a row owned by someone else. The only boundary that stops over-posting is the field allowlist in the action.

How to find it in your code

Find every write and check whether it lists columns or spreads an object. Spreads and permissive schemas are where over-posting hides:

grep -rn "\.update(\|\.insert(\|passthrough\|z.record" actions app

For each hit, confirm two things. First, the schema is a closed z.object without passthrough, so unknown keys are stripped. Second, the write names its columns rather than spreading the parsed input. Then check ownership: any user_id or owner column must come from getUser(), never from the request body. If a write spreads client data or trusts a client-supplied owner, it is a finding.

Common mistakes

  • Myth“I validate the input with Zod, so mass assignment cannot happen.”

    Only a closed schema strips unknown keys. A z.object with passthrough, or a z.record, keeps whatever the client sent, so the extra columns survive validation and reach the write.

  • Myth“Row Level Security will stop a user writing columns they should not.”

    RLS scopes which rows a caller can touch, not which columns they can set, and the service-role admin client bypasses RLS entirely. Neither stops a row owner from setting their own role column.

  • Myth“The form only has the fields I added, so the request is safe.”

    The form is a client artifact. The request body that reaches the server is fully attacker-controlled and can include any column name, whether or not your form rendered it.

  • Myth“My TypeScript types only allow the fields I defined.”

    Types are erased at runtime and enforce nothing on incoming JSON. A typed parameter does not prevent extra keys from arriving and being written if you spread them.

Does SecureStartKit prevent this?

SecureStartKit follows the safe pattern everywhere it writes. `updateProfile` in `actions/user.ts` validates only `fullName` with a closed Zod object and writes only `full_name`, and the `profiles` table has no role or privilege column to over-post into. So the shipped code is not vulnerable. What the defaults cannot do is stop a Server Action you add later from spreading the request body into a write, using a `passthrough()` schema, or trusting a client-sent owner id. Keep schemas closed, list the columns you write, and always source `user_id` from `getUser()`.

Backend-only data access→

Frequently asked questions

What is mass assignment in a Next.js app?
It is when a Server Action or route handler writes attacker-controlled fields it did not intend to expose, usually by spreading the request body into a database insert or update. A user can then set columns like role or user_id that the form never showed.
Does Zod prevent mass assignment?
Only if the schema is a closed z.object, which strips unknown keys. A schema that uses passthrough or a record type keeps extra keys, so they survive validation and get written. The schema must be an allowlist of the fields a user may change.
Can a user set is_admin or role through a Server Action?
Yes, if the action spreads the request body into the write and such a column exists. They add the field to the request payload and it is written alongside the legitimate fields. Listing columns explicitly prevents it.
Does RLS stop over-posting?
No. RLS controls which rows a caller can access, not which columns they can set, and writes through the service-role client bypass RLS anyway. The defense is the field allowlist in the action.

References

  • CWE-915: Improperly Controlled Modification of Object Attributes (MITRE) ↗
  • OWASP: Mass Assignment Cheat Sheet ↗
  • OWASP A01:2025 Broken Access Control ↗
  • Zod: schema validation ↗

Related weaknesses

  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.
  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.
  • RLS Policy With USING but No WITH CHECKAn INSERT, UPDATE, or FOR ALL policy defines USING but no WITH CHECK, so the resulting row is validated by nothing and a user can set a foreign owner id.

Defined terms

  • Server Actions
  • Zod
  • Backend-Only Data Access

Go deeper

  • Next.js Server Actions + Zod: Type-Safe Form Validation
  • JSON to Zod Schema

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns