Public env vars, the Supabase anon key, and row level security
Vite bundles a VITE_ value into client source, and Next.js inlines NEXT_PUBLIC_ at build time. A Supabase publishable key in those names is public by design. Row level security is what limits the rows that key can touch.
By ShipSecurely Editorial · Updated · How we make these guides
Shows up in: Bolt, Vite, Next.js, Supabase, v0
What it is
Two public prefixes put an environment variable in the browser. This page is that pattern in two cases: a Bolt project that uses Vite, and a v0 project that uses Next.js. It is not a count of Bolt or v0 apps, and it is not a claim about every app.
Vite's env guide says a variable prefixed with VITE_ is exposed in client source after bundling, and that VITE_* variables should not contain sensitive information such as API keys, because those values are bundled into source at build time. A name without that prefix is not exposed that way. The guide's example is VITE_SOME_KEY visible through import.meta.env, and DB_PASSWORD undefined there.
Vercel's v0 docs say v0 works with tools like Next.js. Next.js environment variables say that, by default, a variable without the NEXT_PUBLIC_ prefix is only available in the Node.js environment, and that prefixing a variable with NEXT_PUBLIC_ inlines the value into the JavaScript sent to the browser when you run next build. The env option in next.config is a separate path: a name listed there is included in the JavaScript bundle even without NEXT_PUBLIC_.
Supabase API keys say the publishable key is what you ship in a browser, and that anyone can read it, so it only reaches what row level security allows. With nobody signed in, that key is the anon role. With a signed-in user, it is authenticated. The secret key is service_role. It bypasses row level security, and it must not go in a browser, a shipped app, or source control. The legacy anon key is the older publishable key. The legacy service_role key is the older secret key. Supabase says it is deprecating both legacy keys by the end of 2026.
Postgres checks grants before row level security. The same guide says a missing grant returns a permission error, including for service_role, and a policy that matches no rows returns an empty result. Securing your API says a table or view exposed through the Data API without row level security can be accessed by any role with a matching grant. A missing grant on a new table is the separate failure in New Supabase tables need an explicit grant.
Keep the two rules apart.
Safe to put in VITE_ or NEXT_PUBLIC_: the project URL and the publishable key. Supabase documents that key as safe to expose in a web page, and it only reaches what row level security allows. That holds when row level security is enabled and the policies for anon and authenticated are the ones you mean. Vite's warning about API keys still applies to a secret key, a service_role key, or any other credential. The Vite page and the Supabase page do not collapse into one rule.
Not safe to expose: a secret key or a service_role key in VITE_ or NEXT_PUBLIC_. The API keys guide says that key bypasses row level security. Next.js inlines NEXT_PUBLIC_ into the browser bundle at next build. Vite bundles VITE_ into client source.
What row level security has to enforce: policies for anon and authenticated. The API keys guide says policies never apply to a secret key, because service_role has BYPASSRLS. A publishable key in the client is not a substitute for those policies.
How to check in 5 minutes
In a Bolt project that uses Vite, search for
VITE_and forimport.meta.env. Vite's guide says aVITE_value is in the client bundle and a name without that prefix is not. A secret key,service_role, orsb_secret_underVITE_is the failure. The project URL and the publishable key are the names that can use the prefix, and only with row level security on.In a v0 project that uses Next.js, search for
NEXT_PUBLIC_and for names underenvinnext.config. By default, Next.js inlines aNEXT_PUBLIC_value atnext build, and a variable without that prefix stays in the Node.js environment. Theenvoption includes a name in the browser bundle without that prefix. Confirm the secret key is in neither place.In the Supabase dashboard, confirm row level security is enabled on tables the browser client queries, and read the policies for
anonandauthenticated. The API keys guide says to enable row level security on every table before you deploy, and to review those policies. Send one signed-out request with the publishable key on a project you own. Another user's row is the failure. An empty result is a policy that matched no rows.permission denied for tableis a missing grant, covered in New Supabase tables need an explicit grant.
How to fix
Bolt
Bolt projects that use Vite follow the VITE_ rule above. Remove the prefix from any secret, and stop reading that name from import.meta.env in client code. Keep the Supabase project URL and publishable key in VITE_ names only after row level security is on and you have read the policies. Do not put the secret key in a VITE_ variable.
Vite
Treat every VITE_ value as client source. Vite's guide says the prefix exists so the value is bundled, and that it should not hold an API key. A name without the prefix is undefined on import.meta.env. Move a secret to a server or edge function. The Supabase publishable key is the exception Supabase documents, and only with row level security enabled.
v0
On a v0 project that uses Next.js, the public prefix is NEXT_PUBLIC_, not VITE_. Keep the project URL and publishable key in NEXT_PUBLIC_ names. Keep the secret key in a server module that client components do not import, under a name without that prefix. Build again after you change the variable, because Next.js inlines NEXT_PUBLIC_ at next build.
Next.js
By default, Next.js exposes a browser value only when the name starts with NEXT_PUBLIC_. A name without that prefix stays in the Node.js environment, so server code can read process.env.SUPABASE_SECRET_KEY. The env option in next.config is separate: Next.js says a name listed there is included in the JavaScript bundle even without NEXT_PUBLIC_. Do not put a secret key in either place.
Supabase
Enable row level security, then add policies for the roles the browser uses. The API keys guide says to write those policies for anon and authenticated, and that policies do not apply to a secret key. Grant only the commands each role needs. A table with a grant and row level security off is reachable by that role, which Securing your API states. The publishable key in the client does not replace the policies.
Copy the fix prompt
Paste this into Cursor, Lovable, Bolt, or Claude Code. Read the diff before you merge it.
You are checking public environment variables in a Bolt, Vite, v0, or Next.js project that uses Supabase. Do not add features. Do not invent a count of insecure apps. 1. List every VITE_ and NEXT_PUBLIC_ name, and every name under env in next.config. Vite bundles VITE_ into client source. By default Next.js inlines NEXT_PUBLIC_ at next build. A name in next.config env is also in the browser bundle. A Vite name without the VITE_ prefix is not exposed that way. 2. The Supabase project URL and publishable key may use that prefix. The secret key and the legacy service_role key may not. Supabase says the publishable key is public and only reaches what row level security allows. The secret key bypasses row level security. 3. Enable row level security and show the policies for anon and authenticated. Do not turn row level security off. Do not grant every command to anon to hide a permission error. 4. A missing grant is a permission error. A policy that matches no rows returns an empty result. Do not treat that empty result as a missing grant. 5. Do not print secret values. Show the files you changed and the env var names the host needs.
Questions
The anon key is in my JavaScript. Is that a leak?
The legacy anon key and the publishable key are what Supabase says you ship in the browser. They are not a secret by themselves. They are a problem when row level security is off, or when a policy lets anon or authenticated read rows you meant to keep private, because the key only reaches what those policies allow.
Vite says not to put API keys in VITE_. Can the Supabase publishable key use that prefix?
Yes, and the two warnings are different. Vite says VITE_* values are bundled into client source and should not contain sensitive information such as API keys. Supabase says the publishable key is safe to expose because it only reaches what row level security allows, and that a secret key must not go in a browser. Put the publishable key in VITE_ or NEXT_PUBLIC_. Do not put the secret key there.
If row level security is on, can I put the secret key in NEXT_PUBLIC_ or VITE_?
No. The API keys guide says a secret key bypasses row level security, and that policies never apply to service_role. Next.js will still inline NEXT_PUBLIC_. Vite will still bundle VITE_. Row level security does not protect a query made with that key.
Sources
Related
- Secret keys shipped in the frontend
- Supabase tables without row level security
- New Supabase tables need an explicit grant
- Bolt apps, secrets, and Supabase access
- v0 apps, public env vars, and Supabase auth
The weekly fix
The weekly fix: 3 real security mistakes in vibe-coded apps and how to fix them, every Tuesday.
The security issues hitting Supabase, Vercel, Next.js, and Stripe apps
One short email a week, with the fix.