SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Unvalidated Server Action Input
CWE-20High severityNext.jsSupabase

Unvalidated Server Action Input

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

The vulnerable patterns and their fixes

Mass assignment via spread in updateProfile

✗Vulnerablets
'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.

↓the fix
✓Securets
'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.

Missing numeric validation on a payment amount

✗Vulnerablets
'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.

↓the fix
✓Securets
'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.

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

Get SecureStartKit→

How it’s exploited

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.

How to find it in your code

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.

Common mistakes

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

Does SecureStartKit prevent this?

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→

Frequently asked questions

Should I use z.object().strict() to reject unknown keys?
The default z.object() strips unknown keys, which prevents mass assignment as long as you also write only the parsed fields. strict() throws on unknown keys, which is more defensive but breaks future clients that send fields your schema does not yet know about. Use strict() on internal machine-to-machine calls and the default strip behavior on user-facing forms.
What is the difference between parse and safeParse?
parse throws a ZodError when validation fails, which propagates to your nearest error boundary. safeParse returns a result object with success and error and never throws. Use safeParse when you want to return field-level errors to the form, and parse when failure should abort immediately with no user-visible detail.
Does this apply to Server Actions called from server components, not just forms?
Yes. A Server Action invoked from a server component is still a public endpoint from the network perspective. Any HTTP client can POST to it. Validation is required regardless of how the action is called within your own application.
Can I validate with react-hook-form on the client and skip server validation?
No. Client-side validation is a usability feature, not a security control. An attacker skips your client entirely. Server-side Zod validation is the authoritative gate. Use both: client validation for fast feedback, server validation for correctness and security.

References

  • CWE-20: Improper Input Validation ↗
  • OWASP Input Validation Cheat Sheet ↗

Related weaknesses

  • SQL Injection in Supabase QueriesUser input is interpolated into a .or() or .filter() string, or concatenated into a Postgres function’s dynamic SQL, instead of being passed as a bound value.
  • 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.
  • No Rate Limit on Server ActionsA sensitive or expensive Server Action (password reset, magic-link send, AI call, Stripe Checkout creation) runs with no per-IP or per-user rate limit in front of it.

Defined terms

  • Zod
  • Server Actions

Go deeper

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

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