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.
// 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.
// 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.
// 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.
// 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.
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.
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.
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.
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