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.