v0 produces the best-looking prototypes of any AI builder, and it produces them as real Next.js App Router code — which is why teams reach for it to build the actual product, not just the mockup. Most of the time that is a good decision.
It has one consequence that nobody warns you about: v0 loves Server Actions, and a Server Action is a public HTTP endpoint. Not "effectively". Literally.
What a Server Action is, mechanically
When you write this:
"use server";
export async function deleteProject(projectId: string) {
await db.project.delete({ where: { id: projectId } });
revalidatePath("/projects");
}Next.js compiles it into an endpoint. The browser calls it with a POST to the
current page URL carrying a generated action ID in a header and your arguments
in the body. The function runs on your server with whatever arguments arrived.
Three things follow from that, and v0's output usually gets all three wrong:
- Anyone can call it. The action ID is in your client bundle. A user who can
load the page can copy it and send the request with
curl, with any body they like, as many times as they like. - The arguments are untrusted.
projectIdis a string because you typedstring; at runtime it is whatever the caller sent. It might be someone else's project ID. It might be an object. - The UI is not a security boundary. Hiding the delete button from non-owners changes what the button renders. It changes nothing about who can invoke the action.
v0 generates the action, wires the button, and hides the button for the wrong users, because that is what you asked for. It does not add an auth check inside the action because you did not ask, and because in a prototype there is no "wrong user" yet.
The four checks every action needs
Take every file in your project that begins with "use server", and for every
exported function in it, confirm all four. If any is missing, the action is
exploitable.
1. Who is calling?
Read the session on the server, inside the action, from a source the client
cannot forge — a cookie your auth library verifies, not a userId argument.
"use server";
import { auth } from "@/lib/auth"; // your session helper — Supabase, Clerk, Auth.js…
export async function deleteProject(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
// ...
}If the action takes a userId parameter, that is the bug. Delete the parameter
and derive the user from the session.
2. Are they allowed to do this to this thing?
Authentication says who they are. Authorisation says whether this user may touch this row. v0 almost never does the second, because in the prototype every user owned everything.
const project = await db.project.findUnique({ where: { id: projectId } });
if (!project || project.ownerId !== session.user.id) throw new Error("Forbidden");Better still, make the ownership part of the query so there is no window between the check and the write:
const { count } = await db.project.deleteMany({
where: { id: projectId, ownerId: session.user.id },
});
if (count === 0) throw new Error("Forbidden");If your database is Supabase, this is what Row Level Security is for — and here are the RLS mistakes we find in almost every AI-generated app. But note: if your action uses the service-role key, RLS is bypassed and the check above is the only one you have.
3. Is the input what you think it is?
TypeScript types are erased before the request arrives. Validate the shape on
the server, every time. Zod is the usual tool and v0 often already has it in
package.json:
import { z } from "zod";
const Input = z.object({ projectId: z.string().uuid() });
export async function deleteProject(raw: unknown) {
const { projectId } = Input.parse(raw);
// ...
}Declare the parameter as unknown. It is honest about what arrives, and it
forces the parse.
4. How often can they call it?
An action that sends an email, calls a paid API, or creates a record is a cost centre with a public URL. Put a rate limit on it, keyed by user or by IP. On Vercel the simplest path is a small KV or Upstash counter; a dozen lines cover every action in the project through one helper.
The pattern that avoids re-doing this in every file
Once you have written the four checks a few times, wrap them:
// lib/action.ts
export function authedAction<I, O>(schema: z.ZodType<I>, fn: (input: I, user: User) => Promise<O>) {
return async (raw: unknown): Promise<O> => {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await rateLimit(session.user.id);
return fn(schema.parse(raw), session.user);
};
}
// actions/projects.ts
export const deleteProject = authedAction(
z.object({ projectId: z.string().uuid() }),
async ({ projectId }, user) => {
const { count } = await db.project.deleteMany({ where: { id: projectId, ownerId: user.id } });
if (count === 0) throw new Error("Forbidden");
revalidatePath("/projects");
},
);Now a new action is secure by default and an insecure one is visibly not using the helper. That visibility is worth more than any single fix — it is what lets a reviewer, or v0 itself on the next prompt, see at a glance which actions are unprotected.
Two related things v0 gets wrong
Route Handlers have the same problem. Anything in app/api/**/route.ts is
also a public endpoint, and v0 generates those without auth for the same reason.
The four checks apply unchanged.
NEXT_PUBLIC_ on the wrong values. If an action or a route reads a secret
that was prefixed NEXT_PUBLIC_ so a client component could also use it, the
secret is in the browser bundle. This five-minute
audit finds them.
How to find every action in the project
grep -rl '"use server"' app lib actions components 2>/dev/nullEvery file listed is a set of endpoints. Read each exported function against the four checks. On a typical v0 project we find somewhere between five and thirty actions, and it is unusual for more than a couple to pass all four before anyone has looked.
If you would rather have that list handed to you, the free launch audit reads every action and route in your repo and returns each finding reproduced, with a fixed price against the fix — and what we fix in v0 apps covers what else we typically find behind a v0 front end.


