Skip to content
LockShip

Guide · v0 · Updated

Is my v0 app secure? A plain check before you launch

You described an app to v0, watched it write a real Next.js project, and pressed Publish. Now there is a link you’re about to send to people you don’t know, and a quiet question at the back of your mind: if one of them went poking around, what would they find? This guide is how we would answer that for our own app, in plain words, with yours open in the next tab. Nobody can promise you an app is secure, us included. What you can do is look in the handful of places where the trouble usually is.

The honest short answer

v0 gives you a better start than most. Vercel says v0 runs security checks while it writes your code and again before it deploys, and that since launch it has blocked over 100,000 insecure deployments, more than 17,000 of them in July 2025 alone. The most common problems it caught were exposed API keys, misused NEXT_PUBLIC_ variables, and API routes anyone could call without signing in. v0’s security docs add that it analyses your NEXT_PUBLIC_ variables, warns you about risky ones, and can move code to the server.

The catch is that a v0 app isn’t just a page. It is a Next.js app with server code of its own, and that server code is reachable from the internet. The Next.js team says it plainly in its data security guide: a Server Action is “reachable via a direct POST request, not just through your application’s UI,” so you should “verify authentication and authorization inside each one.” A checker can spot a key in the wrong place. It can’t know that your deleteNote action should only ever delete the caller’s own notes.

So the useful question isn’t “is v0 secure?” It is “does every door in my app check who is knocking?” You can find out, and most of it takes an evening.

What your v0 app is made of

  • A Next.js app. v0’s full-stack docs say it defaults to Next.js, which offers “server actions and API routes for colocating frontend and backend code,” and that it writes backend endpoints with App Router conventions. Your pages, your buttons and your backend all live in one project.
  • Hosting on Vercel. v0 deploys to Vercel. Every working branch gets a preview deployment with its own address, and publishing updates your production site.
  • A database through the Vercel Marketplace. v0’s database page offers one-click Supabase, Neon, Upstash and Vercel Blob. Adding one creates an account with that provider and puts the connection details into your project’s environment variables.
  • Environment variables in three places. v0’s docs split them into Production, Preview, and Development, which is what the v0 preview panel uses.

Here is the part that is different from a Lovable or Bolt app. There are two ways into your data. If you use Supabase, your visitors’ browsers may talk to the database directly with the public key, so Row Level Security matters. And your own server code talks to the database too, often with a stronger key, so that code has to do its own checking. If you use Neon, there is no browser door at all: everything goes through your Server Actions and API routes, which makes them the whole lock.

The eight checks

Do them in order; the first one is the most urgent if you haven’t touched the app in a while. Only test an app you own, and write down anything you change.

  1. Find out which Next.js version is live
  2. Read your environment variables the way a stranger would
  3. Treat every Server Action as a public door
  4. Knock on your API routes without signing in
  5. Don’t let middleware be the only lock
  6. Check the database behind it
  7. Lock your previews, old deployments and chats
  8. Add the headers Next.js doesn’t send, and keep source maps private

01

Find out which Next.js version is live

This is the one check where you are not looking for your own mistakes. Next.js itself has had serious security bugs, and your live site keeps running whatever version it was built with until you publish again. Vercel’s React2Shell bulletin gives the quickest way to see it: open your live app, open the browser’s Console, and type this:

Browser console
next.version

It prints something like 16.4.2. If you’d rather use a terminal, this reads the same value from the scripts your homepage loads. We ran it against a fresh Next.js 16.4.2 test app and a live Next.js 15.5 site on Vercel, and it printed the right version for both:

Terminal
APP=https://YOUR-APP.vercel.app
curl -s "$APP/" | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u \
  | while read -r f; do curl -s "$APP$f"; done \
  | grep -oE 'window\.next=\{version:"[^"]+"'

Now compare it with what is safe today.

  • Is your major version still supported? Per the Next.js support policy, 16.x is in Active LTS and 15.x is in Maintenance LTS, which gets only critical fixes and security updates. 14.x and everything older are listed as unsupported.
  • Are you past the latest security release? On 14 October 2026, Next.js shipped fixes in 16.4.1 and 15.5.28 for two critical remote code execution bugs and a denial-of-service bug that hits App Router apps with at least one Server Action. One of the critical bugs is in image optimization and, per its advisory, reaches back as far as Next.js 10. If you are below 16.4.1 on the 16 line or below 15.5.28 on the 15 line, upgrade.
  • Were you online in December 2025? That month brought React2Shell, rated CVSS 10.0, which the Next.js advisory says could allow remote code execution in App Router apps on 15.x and 16.x. It was followed by a denial-of-service bug and a source code leak. Vercel said it was patching existing v0 chats automatically, and still told people to patch published v0 apps right away with the Fix with v0 button in the deploy dialog. Next.js also asked anyone who was online and unpatched on 4 December 2025 to rotate their secrets. If that was you and you never did, do it now.

To upgrade, ask v0 in chat, then publish and run the check again:

Prompt for v0
Upgrade the next package to the newest patch release of the version line this project is on, and tell me the old and new version numbers. Don't change anything else.

New advisories keep coming, so treat these numbers as true on the date at the top of this page. The Next.js blog announces each security release.

02

Read your environment variables the way a stranger would

In a Next.js app, one prefix decides what is public. The Next.js environment variables guide says variables without NEXT_PUBLIC_ stay on the server, while a variable with it is inlined, at build time, “into any JavaScript sent to the browser.” A secret with that prefix is published the moment you deploy. Vercel’s post about v0 shows the exact mistake models make, process.env.NEXT_PUBLIC_DATABASE_URL, and says that in a single month v0 stopped over a thousand people from sharing their Supabase keys, and a thousand more from sharing their OpenAI, Gemini, Claude or xAI keys.

Open Project menu → Settings → Environment Variables in v0 and go down the list. For each name starting with NEXT_PUBLIC_, ask: would I be fine reading this value on a billboard? The Supabase integration, according to Supabase’s Vercel page, adds thirteen variables. Two are public on purpose, NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. Others, like SUPABASE_SECRET_KEY, SUPABASE_JWT_SECRET and POSTGRES_PASSWORD, must never get the prefix. Neon’s integration adds DATABASE_URL, which Neon describes as your pooled connection string. Same rule.

Two habits make this safer:

  • Store real secrets as Secret, not Config. In Vercel, Secret values are write-only after you save them, while Config values stay readable to project members with access. Vercel notes that variables created by an integration can’t be switched to Secret in place; you disconnect, rotate, and reconnect.
  • Never paste a key into a prompt. v0’s projects page says environment variables exist “so you don’t have to expose them directly in your prompts.” Anyone you later share the chat with can read its whole history (check 7).

Then look at what your site actually ships, because a value can also leak when server code hands it to a component. The Next.js data security guide warns this can happen “through how data is fetched or passed to components.” In Chrome DevTools, press Ctrl+Shift+F (Cmd+Option+F on a Mac) to open the Search panel, click through your app, and search for sk_live_, sk-proj-, sk-ant-, sb_secret_, service_role and postgres://. Or let a terminal do it. Put your own pages in PAGES:

Terminal
APP=https://YOUR-APP.vercel.app
PAGES="/ /login /dashboard /settings"
PATTERN="sb_secret_[A-Za-z0-9_-]+|sk_live_[A-Za-z0-9]+|sk-(proj|ant)-[A-Za-z0-9_-]{20,}|service_role|postgres(ql)?://[^\"' ]+"

for p in $PAGES; do curl -s "$APP$p"; done > pages.html
grep -oE '/_next/static/[^"\\]+\.js' pages.html | sort -u \
  | while read -r f; do curl -s "$APP$f"; done > scripts.js
grep -ohE "$PATTERN" pages.html scripts.js | sort -u

We tested this on a small Next.js 16 app with three fake keys. One was a NEXT_PUBLIC_ Stripe key on the homepage. One was an OpenAI key used only on a /notes page. One was a sb_secret_ key that a server page passed to a client component as a prop. The script found all three. With only / in the list, it found just the first, so list every page, including the ones behind sign-in. A key the code reads without the prefix never showed up anywhere. If something real turns up, treat it as already public: rotate it, then move the call to the server. Our guide to leaked API keys in your frontend bundle covers the order to do that in.

03

Treat every Server Action as a public door

When v0 adds a button that saves, deletes, or pays for something, it usually writes a Server Action: a function marked "use server" that runs on Vercel when the button is pressed. It feels like part of the page. It isn’t. It is a separate entry point that anyone can call.

The Next.js data security guide is direct about this. “A page-level authentication check does not extend to the Server Actions defined within it,” it says, and it describes the action as “a separate entry point” that “must verify the caller on its own.” It also asks you to check authorization: not just “is this person signed in?” but “does this user have permission to act on this specific resource?” Next.js does give each action an encrypted ID that changes between builds, which it says “reduces the risk.” It doesn’t hide it. When we built a test app, the ID of our delete action was sitting in the JavaScript the page downloads, as it has to be for the button to work.

Start by finding them. In v0’s Code tab, the search box matches file contents, so searching for use server lists them. If you have the code on your computer, the first command below does the same (the other two are for checks 4 and 6). We ran all three on our test app, and they listed its one action, both API routes, and both files that read the secret key.

Terminal
# Run inside the code you downloaded from v0, or your GitHub copy
# 1. Every Server Action ("use server" at the top of a file or inside a function)
grep -rnE --include=*.ts --include=*.tsx --include=*.js --include=*.jsx \
  --exclude-dir=node_modules --exclude-dir=.next "[\"']use server[\"']" .

# 2. Every API route
find . -path ./node_modules -prune -o -path ./.next -prune -o -name "route.[jt]s" -print

# 3. Every place that uses a Supabase key which skips Row Level Security
grep -rnE --include=*.ts --include=*.tsx --include=*.js --include=*.jsx \
  --exclude-dir=node_modules --exclude-dir=.next \
  "SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY|sb_secret_|service_role" .

Open each action and read its first few lines. You are looking for two things before it touches any data: a check that someone is signed in, and a check that the thing it is about to change belongs to them. Here is what that looks like with Supabase. It type-checks against Next.js 16 and the current Supabase libraries:

app/notes/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'

export async function deleteNote(noteId: string) {
  const supabase = await createClient()

  // 1. Who is asking? getClaims() checks the sign-in token, unlike getSession().
  const { data } = await supabase.auth.getClaims()
  if (!data?.claims) throw new Error('Not signed in')

  // 2. Only touch the note if it belongs to them.
  const { error } = await supabase
    .from('notes')
    .delete()
    .eq('id', noteId)
    .eq('user_id', data.claims.sub)
  if (error) throw error
}

Note getClaims(). Supabase’s Next.js auth guide says to “never trust supabase.auth.getSession() inside server code,” because it reads the session from the cookie without checking it, and anyone can forge a cookie. If an action is missing either check, this prompt usually gets v0 to fix it across the whole project:

Prompt for v0
Go through every Server Action and every API route in this project. For each one, make sure it first checks that the caller is signed in, then checks that the record it reads or changes belongs to that user (or that they are an admin, where that is the point). Use getClaims(), not getSession(). List each one you changed and what it checks now.

Then do the test no code reading replaces. Make two accounts. Signed in as the second, try to see or change something that belongs to the first, for example by opening its page with the first account’s ID in the address bar. You should get nothing. Do this in the browser, on your own accounts. Don’t fire hand-made requests at your live actions, because they really run.

04

Knock on your API routes without signing in

API routes are files called route.ts under your app folder, and their address is their folder path, so app/api/orders/route.ts answers at /api/orders (the route file docs show the pattern). The Next.js authentication guide says to treat them “with the same security considerations as public-facing API endpoints.” Vercel lists unauthenticated API routes among the most common problems v0 catches, which tells you how often models write them.

The second command in check 3 lists every route. For each one that returns data, ask for it the way a stranger would, with no cookie and no key:

Terminal
curl -i https://YOUR-APP.vercel.app/api/orders

On our test app, the route we had written carelessly answered 200 OK with a customer’s email address. The one that checked for a session answered 401 Unauthorized. A route meant for signed-in users should give you the second. If you get data back, ask v0 to add the same two checks as in check 3, then try again. Stick to plain requests like this one. Don’t send POST or DELETE requests to your live app to see what happens.

05

Don’t let middleware be the only lock

Many v0 apps have a file called middleware.ts or, since Next.js 16 renamed it, proxy.ts. It runs before your pages and often sends signed-out visitors to the login screen. That is handy. It just shouldn’t be the only check.

The Next.js authentication guide calls this an optimistic check and says it “should not be your only line of defense”; the real checks belong close to your data. The proxy docs give a very practical reason: “A matcher change or a refactor that moves a Server Function to a different route can silently remove Proxy coverage.” The same guide warns about checks in layouts, which don’t re-run on every navigation.

There is history here too. In March 2025, Next.js published CVE-2025-29927: if your authorization check lived in middleware, it could be bypassed with a special request header. It was fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5. The advisory says Next.js apps hosted on Vercel were automatically protected, and Vercel’s postmortem explains why: its routing runs in a separate system, which made it “incidentally invulnerable.” Apps hosted elsewhere that leaned on middleware alone were at risk. Open your middleware or proxy file and note which pages it protects. Then make sure each of those pages, and every action and route behind them, also checks for itself.

06

Check the database behind it

If you use Supabase

Two doors. The browser’s, guarded by Row Level Security, and your server’s, guarded only by your code if it uses the secret key.

If you use Neon

One door. The browser never talks to Neon; your Server Actions and API routes do, using DATABASE_URL. Checks 3 and 4 are your database’s lock.

The browser door (Supabase). Supabase’s API keys guide says the publishable key is safe in a web page because it only reaches what Row Level Security allows. That makes the rules the real lock, and Supabase’s RLS docs say a table without RLS “is readable and writable by any role with a grant on it.” Find your project address and publishable key (they’re in the two NEXT_PUBLIC_SUPABASE_ variables from check 2) and ask for one row of each private table, as anyone could:

Terminal
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*&limit=1" \
  -H "apikey: YOUR_PUBLISHABLE_KEY"

Real rows from a private table are the thing to fix first. An empty list, [], or a permission error is what you want for private data. This only tests reading, on purpose. To see your write rules, and what a signed-out visitor is allowed to do to each table, use the SQL in our Supabase RLS checklist.

The server door (Supabase). The Supabase integration gives your server SUPABASE_SECRET_KEY, and the API keys guide says secret keys bypass Row Level Security completely. That is fine for admin jobs. But every Server Action or route that uses it has to check who is asking, because the database won’t. The third command in check 3 lists every file that touches it. Where the code is acting for a signed-in user, ask v0 to use the normal server client, the one that carries the user’s session, instead, so your RLS rules apply there too.

Neon. Your connection string can do anything your database user can, so it must never get a NEXT_PUBLIC_ prefix, and each query in an action or route needs the same “signed in, and theirs?” check. One more thing for both: v0’s database page says that for SQL databases, v0 can “create, update, and drop tables.” Read what it plans to run before you approve it on a database with real customers in it.

07

Lock your previews, old deployments and chats

This one isn’t about your production site, which is exactly why it gets missed.

  • Preview and old deployments. Every deployment gets its own address, which Vercel says keeps working for as long as your retention policy allows. That includes last month’s build, with last month’s bugs and versions. In your Vercel project, open Settings → Deployment Protection and check it says Standard Protection, which protects everything except your production domains and is available on every plan. After React2Shell, Vercel’s bulletin “strongly” recommended exactly this, and asked people to audit their shareable links, which let anyone with the link past the protection.
  • Your chat. v0’s sharing page lists four settings: private, your team, anyone with the link, and anyone on the web, which “may be indexed by search engines and appear in public galleries.” People with view access “can see the entire chat history,” so anything pasted into a prompt goes with it. Open Invite and check what yours says.
  • Your GitHub copy. When v0 connects a project to GitHub, its docs say it creates a private repository. If you made it public later, search it for keys, and remember that Git keeps old versions.

Vercel’s guide to locking down deployments adds a reassuring line for v0 users: no chat is ever deployed unless you actively share it.

08

Add the headers Next.js doesn’t send, and keep source maps private

Headers matter less than everything above, and a missing one won’t leak data by itself. They tell browsers how to protect your visitors, though, and Next.js leaves most of them to you. Read yours:

Terminal
curl -sI https://YOUR-APP.vercel.app | grep -iE "^(content-security-policy|content-security-policy-report-only|strict-transport-security|x-frame-options|x-content-type-options|referrer-policy|x-powered-by)"

On our fresh Next.js 16 test app, the only line that came back was X-Powered-By: Next.js, which Next.js adds by default. On Vercel you will also see Strict-Transport-Security, because Vercel applies HSTS automatically on .vercel.app and custom domains. The rest you add in next.config.ts with the headers setting. Ask v0 to merge this into yours:

next.config.ts
import type { NextConfig } from 'next'

const securityHeaders = [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  {
    key: 'Content-Security-Policy-Report-Only',
    value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://*.supabase.co wss://*.supabase.co",
  },
]

const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }]
  },
}

export default nextConfig

We built our test app with exactly this file. All four headers came back, X-Powered-By was gone, and a headless Chrome visit to three pages logged no policy violations. The content security policy is in report-only mode on purpose. MDN describes this as the way to try a policy first: the browser reports what it would block without blocking anything. The policy itself follows the “without nonces” example in the Next.js CSP guide, plus your Supabase address. Once the Console stays quiet on every page, change the key to Content-Security-Policy. If you call other services from the browser, add them to connect-src first.

Source maps turn your compressed JavaScript back into readable source. Next.js turns them off for production “to prevent you leaking your source on the client,” unless someone sets productionBrowserSourceMaps: true. Check:

Terminal
APP=https://YOUR-APP.vercel.app
f=$(curl -s "$APP/" | grep -oE '/_next/static/chunks/[^"]+\.js' | head -1)
curl -s -o /dev/null -w "%{http_code}\n" "$APP$f.map"

404 is what you want; that is what our test app returned. 200 means your source is public. Either ask v0 to remove the setting, or keep the maps and turn on Vercel’s Protected Source Maps, which serves them only to people who can already access the deployment and returns 404 to everyone else.

What a clean result means

If you got through all eight and found nothing, that’s genuinely good news, and you did more than most people do before launch. It means nothing obvious is open today. It doesn’t mean the app is safe forever. A new Server Action written at midnight, or a Next.js advisory next month, can change the answer. Repeat check 1 when Next.js announces a security release, and checks 3 and 4 whenever you add something that saves or sends data.

Where LockShip fits

We built LockShip because the outside-in parts of this list are easy to forget and dull to repeat. You paste your app’s address, prove you own the domain, and the scan reads your app the way a stranger would. It looks for signs the app came from v0. It downloads your homepage and a few other same-site pages linked from it (GET only, and it skips paths that look like logout, delete, checkout or similar), follows the scripts and code chunks those pages load, and reads any public source maps, then searches all of it for secret keys, including ones that ended up there through NEXT_PUBLIC_. On our test app it caught the fake Stripe key and named the file it was in. When the page’s own JavaScript says which Next.js version is live, LockShip compares it with the advisories it knows about, including the October 2026 image-optimizer bug and React2Shell, and it counts Server Actions your pages point to without calling any of them. It finds your Supabase address and public key, sees which tables that key can list, asks each for at most one row, and notes public storage buckets. It tries /.env and /.git, calls API paths it finds named in your JavaScript, like /api/orders, once, with a plain GET and no credentials, and reads your headers, CORS settings, cookies and certificate. It flagged our careless /api/orders route as high.

It is just as important to say what it doesn’t do, and a v0 app has more of that than most. It never sees your server source, so it can’t tell whether a Server Action checks who is calling; it only counts the ones your pages’ code points to, lists them as not tested, and never calls them, because they do real work when called. Checks 3 and 5 are still yours. It only reads, so it can’t tell whether strangers can add, change or delete rows; the report says so and points you to the RLS checklist. It only calls API routes your JavaScript names, and only with GET, so a route your page never mentions goes untested. The same-site crawl is capped and skips risky-looking links, so a page that isn’t linked from the homepage, or that only loads after sign-in, can still be missed — which is why the script in check 2 takes a list of pages. It can’t see your Vercel settings, previews, or v0 chat (check 7), and it never signs in, so it can’t do the two-account test. It is a point-in-time, best-effort check, not a penetration test, and a clean result means “nothing obvious from the outside right now”, not “safe”.

The summary is free: a score and the single worst thing we found. If you want everything, the $39 Launch Safe certificate includes every finding with a fix prompt you can paste into v0, the RLS SQL, a PDF, and seven days of re-scans while you fix things. A passing re-scan gives you a badge you can put on your site.

Want a second pair of eyes from the outside?

Paste your v0 app’s address, prove you own the domain, and see what a stranger can read. The summary is free.

Run a free scan

Before you rely on a result, read the disclaimer.

Questions people ask

v0 already checks my code for security problems. Do I still need to do this?

It helps a lot, so keep it. Vercel says v0 runs security checks while it writes code and again before it deploys, and that it has blocked over 100,000 insecure deployments. But the Next.js docs still tell you to verify authentication and authorization inside every Server Action, and only you know which data is meant for whom. Checks 3, 4 and 6 are about exactly that.

Is the Supabase key I can see in my v0 app a leak?

Not if it is the publishable key (sb_publishable_) or the older anon key. Supabase’s docs say the publishable key is safe to put in a web page because it only reaches what Row Level Security allows. The secret key (sb_secret_) and the older service_role key skip Row Level Security. They belong in server code only, in a variable without the NEXT_PUBLIC_ prefix.

My app is on Vercel. Was it affected by the Next.js middleware bypass, CVE-2025-29927?

Next.js’s advisory says deployments hosted on Vercel were automatically protected. If you exported your v0 app and host it somewhere else, upgrade to at least 15.2.3, 14.2.25, 13.5.9 or 12.3.5, depending on your line. Either way, the lesson stands: a login check in middleware should never be the only one.

Do I have to upgrade Next.js myself if v0 built the app?

Assume yes. After React2Shell, Vercel said it was rolling out patches to v0 chats automatically, and still told people to patch published v0 apps right away with Fix with v0. Your live site keeps running the version it was built with until you publish again. Run check 1 after every big Next.js security release.

Can other people see my v0 chat and my code?

Only if you share them, but the settings are worth a look. A chat can be private, open to your team, open to anyone with the link, or open to anyone on the web, where it may be indexed by search engines. People who can view a chat see its whole prompt history. If v0 connects your project to GitHub, it creates a private repository.

Keep reading

Sources

We checked each of these on 17 October 2026, and ran every command and code sample above against a Next.js 16.4.2 test app with fake keys, built and served on our own machine. v0, Vercel, Next.js and Supabase change quickly, so if something here disagrees with their docs, trust the docs and tell us at hi@lockship.dev.