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
- Server Components, Route Handlers and Server Actions โ
these run on the server, and can read secrets from
process.envsafely. - Environment variables set in the Vercel dashboard
(Project โ Settings โ Environment Variables), not in a committed file.
Without the
NEXT_PUBLIC_prefix, they stay server-side. - Never in a client component, and never in anything a client component imports.
Before you ship
- No secret read anywhere under a
"use client"boundary - Every Server Action checks the session and record ownership
- Every Route Handler in
app/api/does the same - Nothing secret carries the
NEXT_PUBLIC_prefix - Pasted components use your existing auth and data-access paths
- Env vars set in the Vercel dashboard, not committed
- Every key that was ever exposed rotated, not just renamed
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