SecureStartKit ships Zod schemas on its example Server Actions and documents the pattern, but it cannot force every action you add to follow the same discipline.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Parse every Server Action argument through a Zod schema before using any value. Never spread raw FormData or untrusted objects into a database write: write only the specific, allow-listed fields the schema defines. Otherwise an attacker can send extra fields such as role or is_paid (mass assignment, CWE-915).
Where it shows up: A 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.
'use server'
import { createAdminClient } from '@/lib/supabase/server'
export async function updateProfile(userId: string, input: Record<string, unknown>) {
const supabase = createAdminClient()
// VULNERABLE: spreads raw client input into the update.
// An attacker adds role: 'admin' or is_paid: true to the request body.
const { error } = await supabase
.from('profiles')
.update({ ...input }) // never do this
.eq('id', userId)
if (error) throw new Error(error.message)
}The spread operator copies every key from the untrusted input into the SQL UPDATE. Any column the attacker names gets written, including role, plan, or is_paid.
'use server'
import { z } from 'zod'
import { createAdminClient } from '@/lib/supabase/server'
import { getAuthenticatedUser } from '@/lib/auth'
const UpdateProfileSchema = z.object({
display_name: z.string().min(1).max(80),
bio: z.string().max(500).optional(),
})
export async function updateProfile(input: unknown) {
const user = await getAuthenticatedUser() // verify session server-side
const parsed = UpdateProfileSchema.parse(input) // throws on bad shape
const supabase = createAdminClient()
const { error } = await supabase
.from('profiles')
.update({ display_name: parsed.display_name, bio: parsed.bio }) // allow-listed
.eq('id', user.id) // scope to the authenticated user
if (error) throw new Error(error.message)
}The Zod schema defines exactly which fields are accepted. z.object() strips unknown keys by default, and the update statement lists each column explicitly, so role or is_paid cannot reach the database.
'use server'
import { stripe } from '@/lib/stripe/client'
export async function createCharge(formData: FormData) {
// VULNERABLE: amount comes from the client, never validated.
// An attacker sends amount=1 (one cent) or amount=-100.
const amount = formData.get('amount')
const paymentIntent = await stripe.paymentIntents.create({
amount: Number(amount), // NaN, negative, or 1 cent all accepted
currency: 'usd',
automatic_payment_methods: { enabled: true },
})
return { clientSecret: paymentIntent.client_secret }
}Passing an unvalidated string to Number() can produce NaN, 0, negative values, or arbitrarily small amounts. Stripe accepts whatever integer it receives, so the attacker sets their own price.
'use server'
import { z } from 'zod'
import { stripe } from '@/lib/stripe/client'
import { getAuthenticatedUser } from '@/lib/auth'
// The client sends only a known price id; the server owns the amount.
const ChargeSchema = z.object({ priceId: z.enum(['price_starter', 'price_pro']) })
const PRICE_AMOUNTS: Record<string, number> = {
price_starter: 19900, // $199.00 in cents
price_pro: 24900, // $249.00 in cents
}
export async function createCharge(input: unknown) {
await getAuthenticatedUser()
const { priceId } = ChargeSchema.parse(input)
const amount = PRICE_AMOUNTS[priceId] // server-authoritative
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { priceId },
})
return { clientSecret: paymentIntent.client_secret }
}The server owns the price. The client sends only a price identifier from an allow-list, and the server looks up the canonical amount. No client-supplied number reaches Stripe.
A Server Action compiles to a plain POST endpoint. The attacker opens DevTools, submits the form once, and copies the raw request. They then replay it with extra fields appended to the payload, for example name plus role set to admin and is_paid set to true.
The unvalidated action reads the arguments and passes the whole object to a Supabase update, spreading it into the query. Supabase writes every key that matches a column, so role and is_paid are committed. The attacker now has admin rights or a paid plan without paying. This is mass assignment (CWE-915) enabled by improper input validation (CWE-20).
No error surfaces in your logs because the query succeeded. The only evidence is the changed row in the database. The browser form is not an access-control boundary: anyone with curl or a script can craft a request body with arbitrary keys.
Search your actions directory for any function marked use server that reads FormData, then check whether that value passes through a Zod parse or safeParse before use:
grep -rn "formData.get" app/actions
For typed-argument actions, look for parameters typed loosely (any, a bare object, or an untyped record) that reach a Supabase update or insert without an intermediate Zod call.
In code review, treat every Server Action signature as a public API surface: if you would not accept that argument shape from an untrusted HTTP client without validation, you cannot accept it here either.
Consider a custom ESLint rule that flags use server functions with no Zod import, enforced in CI.
Myth“TypeScript types protect me at runtime.”
TypeScript types are erased at compile time. A caller who bypasses your form can send any JSON shape. Zod validates the actual runtime value, not the type annotation.
Myth“My form only sends the fields I built into it, so extra fields cannot arrive.”
Server Actions are plain POST endpoints. Anyone with curl or a script can craft a body with arbitrary keys. The browser form is not an access-control boundary.
Myth“Row Level Security prevents mass assignment because the user cannot escalate their own row.”
RLS controls which rows a role can read or write. It does not restrict which columns are written within an allowed row. An UPDATE that includes role set to admin writes the column if the row is in scope.
Myth“I call safeParse and check success, so validation is optional for non-critical fields.”
Calling safeParse and then ignoring the error, or falling back to the raw data when success is false, gives you no protection. Either throw on failure with parse, or handle the error path and return early.
The kit example actions (auth, billing, user, admin) all parse inputs through Zod schemas and write only allow-listed columns. The project CLAUDE.md instructs adding a Zod schema in lib/schemas for every new action. However, the Next.js framework applies no validation at the Server Action boundary, so any action you add without a Zod parse is fully exposed. The protection is strong by convention, not enforced by the runtime.
How the kit validates Server Actions