A NEXT_PUBLIC_ variable is not a secret
A request fails in the browser with "undefined", someone adds NEXT_PUBLIC_ to the variable name, and the page works. The fix is also a leak: from that build on, the value is in the JavaScript file every visitor downloads. If the value is a database service key or a payment secret, anyone who opens the page can use it.
Why it happens
Next.js replaces every process.env.NEXT_PUBLIC_* reference with its value when it builds the app. The value ends up as plain text in a file under /_next/static/. Other frameworks do the same with their own prefix: VITE_ in Vite and EXPO_PUBLIC_ in Expo. The prefix means "send this to the browser", nothing more.
# .env.local
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
// app/admin/page.tsx ('use client')
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!, // shipped to every visitor
);A Supabase service role key skips every row level security policy, so whoever holds it can read and change every table. A Stripe secret key can issue refunds and read customer data.
Which keys are fine in the browser
Some keys are built to be public: the Supabase URL and anon key (your RLS policies protect the data), a Stripe publishable key (pk_), a Firebase web config, and analytics site ids. Keys that end in SECRET, SERVICE_ROLE, PRIVATE or that hold a database URL with a password are never fine there.
The fix
Drop the prefix and read the secret only in server code: a Server Action, a Route Handler or a Server Component. Add import 'server-only' to the module that holds the client, so the build fails if a client component imports it.
# .env.local
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
// lib/supabase-admin.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
// app/admin/actions.ts
'use server';
import { supabaseAdmin } from '@/lib/supabase-admin';
export async function banUser(userId: string) {
// check that the caller is an admin first
await supabaseAdmin.auth.admin.updateUserById(userId, { ban_duration: '876000h' });
}Then rotate the key. Renaming the variable does not remove the old value from builds that are already deployed, cached by a CDN or saved in someone's browser. Create a new key in the provider's dashboard, update the server variable, redeploy, and revoke the old one.
Verify it
next build and search the client output for the start of each secret, for example grep -rl "sk_live_" .next/static or the first characters of your service role key. The search should find nothing. On the live site, open the browser's developer tools, search all sources for the same text and expect no match.How Zelsis catches this
Zelsis flags NEXT_PUBLIC_ names that point to a server secret, such as ones containing SERVICE_ROLE, SECRET, PRIVATE or DATABASE_URL, and shows the server-only fix on the pull request.
- SEC-36Backend secret exposed through a NEXT_PUBLIC_ environment variable
Using Supabase? Read how to test RLS before you ship, since the anon key is only safe when your policies are right.
Check your own repo
Public repositories scan free. Paste a GitHub URL on the dashboard and every finding comes with the file, the line and a fix.
Scan a repository free