Is my v0 app secure?

v0 writes Next.js, and Next.js has a property that makes security mistakes unusually easy to make and unusually hard to see: the line between code that runs on your server and code that runs in your visitor's browser is invisible. Two files can look identical and behave completely differently.

Everything below follows from that one fact.

1. The boundary you can't see

In the Next.js App Router, a component runs on the server by default. Add "use client" at the top and it ships to the browser instead. Nothing else about the file changes โ€” same imports, same process.env, same syntax.

Which means this line is completely safe in one file and a published credential in another:

const apiKey = process.env.OPENAI_API_KEY

It gets worse, because the boundary is contagious. A component you never marked as client becomes one if it's imported by a component that is. So a file can cross the boundary without you editing it.

The trap in full. In a client component, process.env.OPENAI_API_KEY is undefined โ€” Next.js deliberately doesn't send it. Your app breaks. The obvious fix is to rename the variable NEXT_PUBLIC_OPENAI_API_KEY, because that's what the error messages and Stack Overflow tell you to do for undefined env vars. It works instantly โ€” because you just published the key.

Whenever an environment variable comes back undefined, the question to ask is not "how do I expose this?" but "why is this code running in the browser?" Almost always, the code should have moved server-side rather than the secret moving client-side.

2. Server Actions are public endpoints

This is the one that catches experienced developers, not just new ones.

An async function marked "use server" feels like a private function โ€” you import it and call it like any other. It isn't. Next.js compiles it into an HTTP endpoint with a generated ID, and anyone who finds that ID can call it directly, with whatever arguments they like. The button in your UI that only admins can see is not a security control; it's a suggestion.

Generated code frequently omits the authorisation check, and not out of carelessness โ€” v0 doesn't know what your auth looks like. So you get a perfectly good function that trusts its caller:

"use server"

export async function deleteProject(id: string) {
  await db.project.delete({ where: { id } })   // who is calling this?
}

Every Server Action needs to check the session itself, and check that the session is allowed to touch that specific record:

"use server"

export async function deleteProject(id: string) {
  const session = await auth()
  if (!session) throw new Error("Not signed in")

  const project = await db.project.findUnique({ where: { id } })
  if (project?.ownerId !== session.user.id) throw new Error("Not yours")

  await db.project.delete({ where: { id } })
}

Prompt to paste into v0:

Review every Server Action in this project. Each one is a public HTTP endpoint, so for each: confirm it checks the user is signed in, and confirm it verifies that user is allowed to act on the specific record being passed in. List the ones missing either check and show me the fix.

The same applies to Route Handlers in app/api/. A file existing under /api gives it no protection.

3. NEXT_PUBLIC_ means what it says

Any variable with that prefix is substituted into the JavaScript bundle at build time. It is not configuration on a server โ€” it's a constant in a file anyone can read, and it stays readable in every deployment built from it.

Fine: a Supabase anon key, a Stripe publishable key, a public analytics ID. Not fine: anything with SECRET, PRIVATE, SERVICE_ROLE or TOKEN in the name, or any key you'd be unhappy to see on a stranger's screen.

Worth knowing: renaming it back doesn't undo anything. The value was compiled into bundles that people already downloaded, and it needs rotating at the provider.

4. Generated components don't know your app's rules

v0 is often used to produce a component you paste into an existing codebase, and that component arrives without any knowledge of the conventions already in your app โ€” your auth helper, your data-access layer, the wrapper everyone uses so that queries get scoped to the current user.

So a generated dashboard might query the database directly, and return every row, because nothing told it that your app filters by organisation. It will look right in review โ€” the UI shows the correct data for you, since you can see everything.

When you paste a component in, check what it does with data as carefully as how it looks: does it go through the same access path as the rest of your app, or has it invented its own?

5. Where secrets actually belong

Before you ship

A quick way to catch the big one: build the app and search the output for a distinctive fragment of one of your secrets. If it appears in .next/static, it's in the browser bundle and it's public.

Check it in about ten seconds

vibecheck scans your project or your live URL for the leaks and misconfigurations on this page, in plain English โ€” with a fix prompt you paste straight back into v0.

Scan my app โ€” free