Your Frontend Validation Means Nothing to an Attacker
Client-side validation is UX. Server-side validation is security. And a schema is not an auth guard — here's the fix that still ships an account takeover, and how to catch it in review.
Your Frontend Validation Means Nothing to an Attacker
A bug bounty hunter opens Burp Suite, loads your checkout page, and adds a $500 laptop to their cart. The frontend form won't let them enter a negative price — there's a min={0} on the input, and the React validation schema rejects anything below zero. So they intercept the POST request before it hits your server and change the price field in the JSON body from 500 to -500.
The order goes through. Your server charges them negative five hundred dollars. Depending on your payment processor, they either get a refund credited to their card, or your backend throws an uncaught error and the order is created for free.
This isn't a contrived example. It's an old, well-documented bug class, and it's almost always caused by the same assumption: "we validated this on the frontend, so the server can trust it."
The Mistake
Here's the pattern in a Next.js server action:
// actions/checkout.ts
'use server';
export async function createOrder(formData: FormData) {
const userId = formData.get('userId') as string;
const productId = formData.get('productId') as string;
const quantity = Number(formData.get('quantity'));
const price = Number(formData.get('price')); // <-- trusting the client
// Create the order with whatever the client sent
const order = await prisma.order.create({
data: {
userId,
productId,
quantity,
unitPrice: price,
total: price * quantity,
status: 'CONFIRMED',
},
});
return { success: true, orderId: order.id };
}The React form that calls this action validates that quantity is between 1 and 100 and that price matches the displayed value. But server actions — like all API endpoints — are reachable directly. Next.js compiles each one into a POST to the route it lives on, identified by an action ID sent in a next-action header (that's the literal header name in Next 16.1.6's app-router-headers), and that ID ships to the browser in the page's own payload. So "nobody knows the URL" was never the protection: read the ID out of the page source and you can call the action with curl, with no cookie and no form.
Two separate things are wrong here, and they get conflated constantly:
priceis client-supplied data being trusted. That's the validation bug.userIdis client-supplied identity. That's the worse one. No schema can fix it, because-500is an invalid price but another user's ID is a perfectly valid string.
Why This Breaks Everything
Client-side validation is UX. It gives users instant feedback without a round trip. That's the only thing it is. It provides zero security.
A browser is a tool the user controls. They can:
- Open devtools and edit the form fields before submission
- Use Burp Suite or a browser extension to intercept and rewrite the HTTP request in flight
curlyour API directly, bypassing the browser entirely- Write a script that calls your endpoints in a loop with crafted payloads
None of these require any special skills. Intercepting a POST request and editing a JSON field is a first-day exercise with any intercepting proxy.
The consequences scale with what you fail to validate:
- Price/quantity manipulation → orders placed for free or negative totals, inventory corruption
- Type confusion → passing a string where you expect a number, breaking downstream processing or causing unhandled exceptions
- Range violations → age: -1, quantity: 9999999, percentage: 110, inserting absurd values into your database
- Role escalation → registration endpoints that accept a
rolefield without ignoring it - Foreign key spoofing → passing another user's
resourceIdand accessing data you shouldn't (that's IDOR, a close sibling) - Identity spoofing → passing another user's
userIdas the row you're writing to, which is IDOR with aWHEREclause on it
The tricky part is that this class of bug often doesn't crash anything immediately. The corrupted data sits in your database, downstream jobs process it silently, and you find out weeks later when a report shows negative revenue or a customer disputes a charge.
Real-World Examples
Example 1: Price Passed From the Client
The classic. The product price comes from the frontend instead of being looked up server-side.
// Before: price comes from the request body
export async function POST(request: Request) {
const { productId, quantity, price } = await request.json();
const charge = await stripe.paymentIntents.create({
amount: price * quantity * 100, // whatever the client says
currency: 'usd',
});
await db.order.create({ data: { productId, quantity, unitPrice: price } });
return Response.json({ clientSecret: charge.client_secret });
}// After: identity from the session, price from the database, period
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: 'Sign in required' }, { status: 401 });
}
const parsed = z.object({
productId: z.cuid(),
quantity: z.number().int().min(1).max(100),
}).safeParse(await request.json());
if (!parsed.success) {
return Response.json({ error: 'Invalid input' }, { status: 400 });
}
const { productId, quantity } = parsed.data;
const product = await db.product.findUniqueOrThrow({
where: { id: productId, inStock: true },
select: { priceCents: true },
});
const charge = await stripe.paymentIntents.create({
amount: product.priceCents * quantity, // integer minor units, from the DB
currency: 'usd',
});
await db.order.create({
data: {
userId: session.user.id, // the session, never the body
productId,
quantity,
unitPriceCents: product.priceCents,
},
});
return Response.json({ clientSecret: charge.client_secret });
}The key change: price is never accepted from the client. You only accept what you need — a product ID and a quantity — and you look up everything else from authoritative sources. The buyer's identity comes from the same place.
Two details worth copying. findUniqueOrThrow here filters on inStock: true alongside the unique id — Prisma allows extra non-unique predicates in a findUnique where clause, so "does this product exist and is it sellable" is one indivisible lookup rather than a fetch followed by an if somebody later deletes. And the amount is an integer in minor units all the way through. Notice the "before" version multiplied by 100 to reach cents; the moment money crosses a boundary as a float and gets rescaled, "is this dollars or cents?" becomes a question your code answers differently in two places. Store priceCents, never rescale.
Example 2: A Server Action With No Guard and a Client-Supplied userId
Forms validate on the client, so the server action just uses what it gets.
// Before: raw FormData, no auth check, no shape check
export async function updateProfile(formData: FormData) {
const userId = formData.get('userId') as string;
const age = Number(formData.get('age'));
const bio = formData.get('bio') as string;
await prisma.userProfile.update({
where: { userId },
data: { age, bio }, // age: -5 and bio: "<script>alert(1)</script>" both accepted
});
}Here is the fix people write, and it is still exploitable:
// STILL BROKEN: validated, unguarded, and keyed on a client-supplied userId
const badSchema = z.object({
userId: z.cuid(),
age: z.number().int().min(13).max(120).optional(),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const parsed = badSchema.safeParse({
userId: formData.get('userId'), // <-- a valid cuid. just not yours.
age: formData.get('age') ? Number(formData.get('age')) : undefined,
bio: formData.get('bio'),
});
if (!parsed.success) return { error: 'Invalid input' };
await prisma.userProfile.update({
where: { userId: parsed.data.userId },
data: { age: parsed.data.age, bio: parsed.data.bio },
});
}Every field is now shape-checked, type-checked and range-checked, and the action will happily rewrite any profile in the database for anyone who can send it a POST — including someone who never signed in. z.cuid() proves the string is a well-formed cuid. It cannot prove it's your cuid. Adding a schema to an unguarded mutation buys you well-formed unauthorized writes.
// After: guard first, identity from the session, schema for the rest
'use server';
const updateProfileSchema = z.object({
age: z.number().int().min(13).max(120).optional(),
bio: z.string().max(500).optional(),
});
export async function updateProfile(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
return { error: 'You must be signed in to do that.' };
}
const parsed = updateProfileSchema.safeParse({
// Zod does not coerce for you: z.number() rejects the string "25".
// FormData values are always strings, so convert at the boundary.
age: formData.get('age') ? Number(formData.get('age')) : undefined,
bio: formData.get('bio'),
});
if (!parsed.success) {
return { error: z.flattenError(parsed.error).fieldErrors };
}
await prisma.userProfile.update({
where: { userId: session.user.id }, // never formData.get('userId')
data: parsed.data,
});
}Two changes, and the second one is the one people skip. userId is gone from the schema entirely — a field the client controls can never be the thing that decides whose row gets written. And the action refuses to run for an anonymous caller before it looks at the body at all.
That ordering is deliberate: authenticate, then authorize, then validate. Validation tells you the request is well-formed. Only the guard tells you the caller is allowed to make it. If your codebase has more than a couple of these, put the check behind one function (requireUser(), requireAdmin()) and call it on the first line of every action — hand-copied auth checks drift, and a drifted copy is an unauthenticated mutation.
Zod validates type and enforces range in one shot, so if age is -5 or bio is 10,000 characters it fails before touching the database. It does not, however, convert types unless you ask it to: z.number() rejects the string "25", which is what every FormData value is. Either convert at the boundary as above or use z.coerce.number() — but be aware z.coerce.number() accepts "" as 0.
Example 3: Role Escalation via User Registration
This one is embarrassing when it gets found. The role field is in the request body and the server doesn't filter it.
// Before: registration endpoint spreads the request body into the create call
export async function POST(request: Request) {
const body = await request.json();
const { password, ...rest } = body;
const user = await prisma.user.create({
data: {
...rest, // <-- carries body.role straight into the column
password: await hash(password),
},
});
return Response.json({ id: user.id });
}An attacker sends { "email": "attacker@example.com", "password": "...", "role": "ADMIN" } and becomes an admin. I ran exactly this against Prisma 6.19.2 on Postgres (inside a transaction I rolled back) and the created row came back with role: "ADMIN".
Note the shape of the bug, because it decides whether you're vulnerable. Prisma validates the argument object against the model, so if the spread carries any key that isn't a column — leaving password in the spread when the column is passwordHash, say — it throws PrismaClientValidationError: Unknown argument, and the request 500s instead of escalating. That accidental defence disappears the moment the attacker's field names line up with your column names. For role, isAdmin and verified they do, because those columns are exactly what the attacker read off your own schema. Don't rely on it; the "it crashed instead of escalating" outcome is luck, not a control.
// After: explicit allowlist — only extract what you intend to use
export async function POST(request: Request) {
const parsed = z.object({
email: z.email(),
password: z.string().min(8).max(128),
}).safeParse(await request.json());
if (!parsed.success) {
return Response.json({ error: 'Invalid input' }, { status: 400 });
}
const user = await prisma.user.create({
data: {
email: parsed.data.email,
password: await hash(parsed.data.password),
role: 'USER', // always hardcoded — never from client
},
});
return Response.json({ id: user.id });
}The schema acts as the allowlist. Fields not in the schema don't exist from the server's perspective. role isn't merely absent from the schema — it's written literally, so there is no code path that can set it to anything else.
Example 4: Content Type and File Size Validation That Lives Only in the Browser
File uploads have a accept attribute on the input and a maxSize check in the onChange handler. The server accepts whatever bytes arrive.
// Before: no server-side file validation
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('avatar') as File;
const buffer = Buffer.from(await file.arrayBuffer());
const path = `/uploads/${file.name}`;
// file could be 500MB, could be a PHP script, could be an SVG with XSS
await writeFile(path, buffer);
return Response.json({ path });
}// After: validate server-side before doing anything with the bytes
const ALLOWED_TYPES = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
} as const;
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
const UPLOAD_DIR = process.env.UPLOAD_DIR!; // never a client-supplied path
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: 'Sign in required' }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get('avatar');
if (!(file instanceof File)) {
return Response.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return Response.json({ error: 'File too large' }, { status: 413 });
}
const ext = ALLOWED_TYPES[file.type as keyof typeof ALLOWED_TYPES];
if (!ext) {
return Response.json({ error: 'File type not allowed' }, { status: 415 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// file.type is client-controlled too. For anything sensitive, confirm the
// magic bytes with a library like file-type before trusting the extension.
const path = `${UPLOAD_DIR}/${crypto.randomUUID()}.${ext}`;
await writeFile(path, buffer);
return Response.json({ path });
}Three things there. An unauthenticated upload endpoint is a free file host regardless of how well you validate the bytes, so the guard comes first. The size check runs before arrayBuffer(), because buffering the whole file into memory to then reject it is a denial-of-service you wrote yourself. And the extension is looked up from the server's own allowlist rather than sliced out of file.type — file.type is a client-supplied string, and it is the last thing that should be deciding what you name a file on disk.
The Fix Pattern
Guard and validate at every system boundary. In practice, that means: every API route handler and every server action opens with an auth check and a schema parse, in that order. No exceptions.
'use server';
// The pattern to stamp everywhere
export async function myServerAction(rawInput: unknown) {
const guard = await requireUser(); // or requireAdmin() — one shared helper
if (!guard.ok) return guard.response;
const parsed = mySchema.safeParse(rawInput);
if (!parsed.success) {
return { error: z.flattenError(parsed.error).fieldErrors };
}
const data = parsed.data; // fully typed and validated from here on
// ...and every "who" in the rest of the action comes from
// guard.principal.userId, never from `data`.
}A few rules that follow from this:
Never take the actor's identity from the request. userId, accountId, tenantId, authorId — if it decides whose data is read or written, it comes from the session, not the body. This is the one that survives code review most often, because the code looks validated.
Never accept prices, totals, or discount amounts from the client. Look them up from your database or compute them server-side. A checkout flow should only need a cart item list (product IDs + quantities) — everything financial gets calculated from authoritative data.
Never spread or destructure request bodies into database writes. prisma.user.create({ data: { ...body } }) is one refactor away from mass assignment. Extract only the fields you need by name.
Validate shape, type, and range — not just presence. z.string() accepts an empty string. z.string().min(1).max(200) doesn't. Be specific.
Share schemas between client and server. Define your Zod schema once, import it in the server action, and in the React Hook Form resolver. One source of truth, zero duplication.
How to Catch This in Review
When reviewing PRs, look for:
- A mutation with no auth check on the first line — read the top of every new server action and route handler before you read anything else. A missing guard outranks every other finding on this list
- A
userId(ortenantId,orgId) arriving informDataor the JSON body and then used in awhereclause — validated or not, that's an account takeover - Any
request.json()orformData.get()result used directly without a schema parse — this is untrusted input flowing into your logic - Object spread into Prisma/database calls:
prisma.model.create({ data: { ...body } })— classic mass assignment - Business-critical numeric fields (price, quantity, discount) accepted from the request body — these should always come from the database or be computed server-side
- File handlers that only check
file.typewithout validating size or magic bytes role,isAdmin,permissions,verifiedfields in a registration or update endpoint — these should never be writable by the client directly- Client and server using the same validation function — if it's the same function running in both places and only called once on the server after being called client-side, the server call might have been removed
The question to ask in review: "What happens if I send this request with curl, no cookie, every numeric field set to -1, every string 10,000 characters long, and someone else's ID in the ID fields?" If the answer isn't "it gets rejected before reaching any business logic," something is missing. The no-cookie half of that question is the one worth actually running.
Lint rules help less here than you'd hope: "this function calls a guard on line one" is easy to state and hard to express as a generic rule, since the guard is your own helper. What does work is making it structurally impossible to forget — one exported helper that every mutation must call to get a user ID at all, so an action that never calls it has no user ID to write with.
The Rule: Client-side validation is for UX. Server-side validation is for security. And a schema is not a guard — validation proves the request is well-formed, only the auth check proves the caller is allowed to make it.
Comments (0)
No comments yet. Be the first to share your thoughts!