If you've ever stared at a .env.local full of Supabase keys and wondered "wait, which of these is safe to ship to the browser?" — you're not alone. It's one of the most common questions I see, and getting it wrong ranges from "totally fine" to "someone just dumped your entire users table." Let's clear it up for good.
The one rule that explains everything
In Next.js, any environment variable prefixed with NEXT_PUBLIC_ gets inlined into the JavaScript bundle at build time. Same story for VITE_ in Vite apps and EXPO_PUBLIC_ in Expo. The prefix is not a suggestion — it's an instruction to the bundler: copy this value into code that runs in the user's browser.
So the question "is this variable safe to expose?" is really "is it safe for this value to sit in plain text inside the client bundle, readable by anyone who opens DevTools?"
For Supabase, the answer depends entirely on which key you're talking about.
Every Supabase project has two keys, and they are not equals
Open your project's API settings and you get two keys. They look similar. They could not be more different.
1. The anon key (newer projects call this the publishable key, formatted sb_publishable_...). Its JWT carries "role": "anon". This key is public by design. It's meant to ship in the browser. It is not a secret, and you do not need to rotate it if it "leaks" — because it was never hidden in the first place. What keeps your data safe when this key is used is Row Level Security (RLS): the anon key can only do what your RLS policies allow.
2. The service_role key (formatted sb_secret_... on newer projects). Its JWT carries "role": "service_role". This key bypasses Row Level Security entirely. Full read, write, and delete on every table, plus Storage. It exists for trusted server environments only. It must never appear in a browser bundle, a public repo, or any client-side config.
Here's a reliable way to tell any Supabase JWT apart — decode the middle segment (it's base64url) and read the role:
# paste your key in place of $KEY
echo "$KEY" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | grep -o '"role":"[a-z_]*"'
# => "role":"anon" -> public, safe for the browser
# => "role":"service_role" -> secret, server-only, never public
(The tr '_-' '/+' converts base64url to standard base64 so base64 -d can read it.) The newer sb_publishable_ / sb_secret_ keys aren't JWTs, but the naming makes the distinction obvious — secret means secret.
So, the safe list
Safe to expose (belongs in NEXT_PUBLIC_):
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_xxx # or the legacy anon JWT
The project URL is public — it's just an HTTPS endpoint. The anon key is public by design. Ship them.
Never public (no NEXT_PUBLIC_ prefix, ever):
SUPABASE_SERVICE_ROLE_KEY=sb_secret_xxx
DATABASE_URL=postgresql://postgres:password@db.xxxx.supabase.co:5432/postgres
DATABASE_URL contains your database password and gives direct Postgres access. That connection bypasses RLS the same way service_role does — the postgres role is not subject to your policies. Both stay strictly server-side.
Keeping service_role strictly server-side
The safe pattern: the browser talks to Supabase with the anon key (protected by RLS), and anything that genuinely needs to bypass RLS runs on a server you control. Next.js gives you three good homes for that.
Route handlers (app/api/.../route.ts):
// app/api/admin-report/route.ts
import { createClient } from '@supabase/supabase-js'
// No NEXT_PUBLIC_ prefix -> stays on the server, never bundled
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function GET() {
// runs server-side only; safe to use the service_role client here
const { data, error } = await admin.from('reports').select('*')
if (error) return Response.json({ error: error.message }, { status: 500 })
return Response.json(data)
}
Server Actions work the same way — they only ever execute on the server, so reading process.env.SUPABASE_SERVICE_ROLE_KEY there is fine:
'use server'
import { createClient } from '@supabase/supabase-js'
export async function deleteAccount(userId: string) {
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
await admin.from('profiles').delete().eq('id', userId)
}
Supabase Edge Functions get the service_role key from their own secret store (Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')), completely outside your frontend build — a great home for privileged jobs.
The mental model: if a file has 'use client' at the top, or gets imported by one, assume everything in it is public. Never read a secret there.
Catch the mistake before it ships
The classic disaster is one character of muscle memory: someone types NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY because every other Supabase var starts that way. Now your RLS-bypassing key is inlined into the client bundle.
Grep for it. Add this to CI or a pre-commit hook:
# Fails if any NEXT_PUBLIC_ / VITE_ / EXPO_PUBLIC_ var references service_role
git grep -nE '(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_)[A-Z_]*SERVICE_ROLE' && {
echo "FATAL: service_role key exposed via a public env prefix"; exit 1
} || echo "OK: no service_role key behind a public prefix"
It's also worth scanning the built output itself — a secret can leak through a hardcoded string even without the prefix:
# after `next build`
grep -rE 'sb_secret_|service_role' .next/static && \
echo "FATAL: secret found in client bundle" || echo "clean"
If a secret already leaked
Two things, in order. First, rotate the key in your Supabase dashboard immediately — a leaked service_role key is a live master key until you do. Second, remember that deleting the file in a new commit is not enough: the value lives in your git history forever, and anyone can git log -p it back. You have to purge history with git filter-repo (or the BFG), then force-push:
git filter-repo --path .env.local --invert-paths
Rotate and purge. One without the other leaves you exposed.
The part people forget: the anon key is only as safe as your RLS
Shipping the anon key is safe only because RLS stands between it and your data. If a table has RLS disabled, or has a policy like USING (true) with no TO clause, then that "public" anon key can read every row — for every user. A SELECT policy of USING (true) is genuinely world-readable. (It's only world-writable if a permissive INSERT/UPDATE/ALL policy exists too — but world-readable is already a breach for most apps.)
Remember the anatomy:
-
USINGfilters which existing rows a role can see, and which it may update or delete. -
WITH CHECKvalidates rows being inserted or updated;INSERTpolicies haveWITH CHECKonly. - A role needs both a matching policy and the underlying table
GRANT. Permissive policies OR together; restrictive ones AND. - A policy with no
TOclause applies to all roles,anonincluded. - For an anonymous visitor,
auth.uid()isNULL— so any policy that quietly assumes a logged-in user needs testing from the anon perspective.
If you want to actually check your own project rather than take my word for it, I put together a free, read-only audit: github.com/cekuu35/supabase-rls-leak-demo. It's a tiny reproducible leak plus a set of SQL queries you can paste into the Supabase SQL editor to list every table with RLS off and every policy that resolves to true for anon. Run it against a staging project and see what falls out — it's usually one or two tables you forgot about.
The takeaway
-
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY: public by design, ship them. -
SUPABASE_SERVICE_ROLE_KEYandDATABASE_URL: server-only, no public prefix, ever. - Decode the JWT role when you're unsure;
git grepfor the prefix mistake in CI. - The anon key's safety is your RLS — so audit your policies, don't assume them.
If you'd rather not hand-roll the whole audit, I also maintain a $29 RLS Audit Kit — the same read-only checks packaged with a policy checklist and a few "here's the fix" templates for the leaks it turns up. Totally optional; the free demo above will already get you most of the way. Either way, go check your policies today — future-you will be glad you did.
Top comments (0)