Free launch audit — a senior engineer reads your repo and tells you exactly what's blocking launch.Claim yours
Production engineering

v0 Server Actions are public endpoints. Here is how to secure them

Every Server Action in a v0 project can be called by anyone on the internet with any arguments, whatever your UI shows. Here is what that means and the four checks each one needs.

GEGen2ProdProduction engineering

4 min read

A sleek glass door standing open in an otherwise solid wall, light spilling through

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:

  1. 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.
  2. The arguments are untrusted. projectId is a string because you typed string; at runtime it is whatever the caller sent. It might be someone else's project ID. It might be an object.
  3. 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.

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

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

Share

Your AI-built product deserves a real launch.

Start with the free audit. In 48 hours you'll know what's broken, what each fix costs, and whether it's even worth doing — before you've spent a dollar.

Free audit, no card · Fixed price before we start · Your code stays yours