All posts
VibeZero Team6 min read

How to secure a v0 app

v0 blocks the NEXT_PUBLIC_ mistake and does not check who calls your Server Actions. Five fixes for a v0-generated Next.js app, worst first, with code.

  • security
  • vibe coding
  • v0

v0 is the unusual case in this series: it ships real security checks of its own, and Vercel documents them. That changes what is worth writing about. Rather than repeating what v0 already catches, this post is about the gaps that survive generation, which are the ones specific to the framework it generates.

All five are Next.js App Router problems. None is a rewrite.

1. Audit NEXT_PUBLIC_, then stop thinking about it

Next.js exposes any environment variable prefixed with NEXT_PUBLIC_ to the browser. That is deliberate design and it is also the single most common way an AI-generated Next.js app publishes a credential, because the prefix is the difference between a private value and a public one and nothing about the code looks different either way.

Credit where it is due: v0 checks for this. Vercel’s v0: vibe coding, securely lists automated checks for “secrets exposed in client-side code or public repositories” and “misuse of NEXT_PUBLIC_ variables that leak production credentials,” and reports over 100,000 insecure deployments blocked since launch, more than 17,000 in a single month. The keys that would have leaked included Google Maps, reCAPTCHA, EmailJS, and PostHog.

Verify anyway, because a check that runs at generation time does not cover the variable you added by hand afterwards in the Vercel dashboard:

next build
grep -rEo "NEXT_PUBLIC_[A-Z0-9_]+" .next/static/ | sort -u

Every name that comes back is readable by every visitor. Judge them by what they are, not by whether the app works: a publishable Stripe key or a PostHog project key belongs there, a database URL or an API secret does not. Anything in the second group gets rotated first and renamed second, since a value in a deployed bundle should be treated as already taken.

2. Re-authorize inside every Server Action

This is the one that catches experienced developers, because the mistake looks like correct code.

A Server Action is a public HTTP endpoint. Next.js is explicit about it in its data security guide: “when a Server Action is created and exported, it is reachable via a direct POST request, not just through your application’s UI,” and the framework’s protective measures “reduce the risk in cases where an authentication layer is missing. However, you should still treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one.”

The page states the consequence even more directly: “A page-level authentication check does not extend to the Server Actions defined within it.”

Generated code routinely gets this backwards, guarding the page and leaving the action open:

// app/admin/page.tsx
export default async function AdminPage() {
  const session = await auth();
  if (!session?.user?.isAdmin) redirect("/login");

  return (
    <form
      action={async () => {
        "use server";
        // Nothing here checks anything. A direct POST reaches it.
        await db.record.deleteMany();
      }}
    >
      <button>Delete records</button>
    </form>
  );
}

The redirect decides which UI renders. It has no bearing on who can POST to the action. The fix is to check inside:

action={async () => {
  "use server";
  const session = await auth();
  if (!session?.user?.isAdmin) throw new Error("Unauthorized");
  await db.record.deleteMany();
}}

And authentication is only half of it. Ownership has to be checked against the specific resource, or you have written an IDOR with extra steps:

// app/actions.ts
"use server";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const post = await db.post.findUnique({ where: { id: postId } });
  if (post?.authorId !== session.user.id) throw new Error("Forbidden");

  await db.post.delete({ where: { id: postId } });
}

3. Do not let the proxy be your only gate

If your only authorization check lives in proxy.ts (named middleware.ts before Next.js 16), one framework bug removes all of it. That is not hypothetical: CVE-2025-29927, disclosed in March 2025 with a CVSS of 9.1, let an attacker skip middleware entirely by spoofing the x-middleware-subrequest header, which the framework trusted without verifying its origin. Every route whose protection lived only there became public to anyone who knew to add one header.

Two things follow. First, be on a patched version. The flaw affected releases before 12.3.5, 13.5.9, 14.2.25, and 15.2.3, and a generated package.json often pins whatever version the model remembered:

npm ls next
npm audit --omit=dev

Second, and more durably: treat the proxy as routing and coarse redirects, not as your authorization layer. Enforcement belongs next to the data, in the Server Action, the Route Handler, or a data access layer that every read goes through. When the check sits beside the query, a bypass of the edge does not become a bypass of your rules.

4. Return what the UI needs, not the database row

Server Component and Server Action return values get serialized and sent to the client, so select * becomes a published document. Password hashes, internal flags, other people’s email addresses, and soft-deleted rows travel with it, and none of it is visible in the rendered page, which is what makes it easy to miss.

// BAD: the whole record crosses the boundary.
export async function updateUser(data: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  return db.user.update({
    where: { id: session.user.id },
    data: { name: data.get("name") as string },
  });
}

// GOOD: return only the answer.
export async function updateUserSafe(data: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");
  await db.user.update({
    where: { id: session.user.id },
    data: { name: data.get("name") as string },
  });
  return { success: true };
}

The same rule applies to props: a Server Component passing a full user object into a "use client" component has shipped that object to the browser. Next.js recommends a data access layer marked import "server-only" that returns narrow objects, which also gives you one place to put the authorization checks from step 2.

5. Update what shipped vulnerable

Generated manifests reach for versions the model remembers, and nothing bumps the lockfile afterwards. Step 3 already gave you the reason to care about the next version specifically; the rest of the tree has the same problem.

npm audit --omit=dev
npm update

Re-run your tests, then confirm the audit is genuinely clean rather than merely quieter.

What this does not cover

These five close the gaps that are specific to a v0-generated Next.js app, and they are not a security review. Nothing above inspects your Content Security Policy, your file upload handling, whether your [param] folders validate what arrives in them, or the secret that leaked in an early commit and is still in your git history even though the working tree is clean. Those belong to the common vulnerability classes that need something reading the whole repository rather than a checklist.

VibeZero scans across five layers, with Gitleaks and TruffleHog over the history for the keys step 1 only finds in the current build, and OSV-Scanner and Grype over the lockfile for step 5. Either way, a fix counts once something other than the tool that wrote the code confirms it, which is a problem worth understanding on its own.

Generation-time checks and request-time checks

v0’s checks are good and they are early. They read code before it deploys, which catches a secret in a variable name and cannot catch a Server Action that forgets who is calling it, because that failure only exists at request time when someone sends a POST nobody wrote a form for.

That is the line to keep in mind with any generator that advertises security scanning, including Lovable’s, whose scanner checked that a database policy existed without checking that it denied anything. A check that runs before the request cannot answer a question about the request. Both kinds are worth having, and only one of them tells you whether your app is safe to launch.

ShareXLinkedIn