Keeping Secrets Out of a Next.js App
In Next.js, a secret is safe only if it never leaves the server. The four ways it escapes are the NEXT_PUBLIC_ prefix (inlined into the browser bundle), importing a secret-reading module into a "use client" component, hardcoding the value in source, and leaving it in git history. The fix for all four: read secrets from unprefixed env vars in server-only code, and rotate anything that has already shipped.
Next.js blurs the line between server and client on purpose — the same repo renders on both. That's great for productivity and dangerous for secrets, because it's genuinely easy to write code that looks server-side but gets bundled and shipped to every visitor. Here are the four escape routes, worst-first, and how to close each.
1. The NEXT_PUBLIC_ prefix (the most common leak)
Any environment variable named NEXT_PUBLIC_* is inlined into the client JavaScript at build time and shipped to every browser. That's the intended behavior — it's how you expose safe public values like your site URL or the Supabase anon key. The problem is when a real secret gets that prefix, usually copy-pasted from a tutorial or added "just to make it work":
// ❌ This publishes your master key to every visitor.
process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY
The Supabase service_role key bypasses all RLS, so publishing it is a full database compromise. GuardLayer flags a secret-looking NEXT_PUBLIC_ value as critical — but it deliberately stays quiet on genuinely public ones (anon keys, pk_ publishable keys, analytics tokens), because not every NEXT_PUBLIC_ var is a leak. Here's live engine output on the combined mistake — a client module reading the service_role key:
- Criticallib/supabaseAdmin.ts:7
Service role key exposed to the client
Never prefix the service role key with NEXT_PUBLIC_. Read it only in server code via process.env.SUPABASE_SERVICE_ROLE_KEY, and rotate the key immediately since it has been exposed. - Criticallib/supabaseAdmin.ts:7
Service role key used in client-side code
Move all service-role usage into a server context (Route Handler, Server Action, or server-only module). On the client use only the anon key, protected by RLS. - Criticallib/supabaseAdmin.ts:7
Secret exposed through NEXT_PUBLIC_
Drop the NEXT_PUBLIC_ prefix and read the value only on the server. Publishable/anon keys are fine to expose; secret keys, tokens, and passwords are not — rotate any that have shipped.
The fix: drop the prefix and read it only on the server.
import "server-only";
import { createClient } from "@supabase/supabase-js";
// server-only throws at build time if this is ever imported client-side.
export const adminClient = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // no NEXT_PUBLIC_ prefix
);
2. Importing a server module into a client component
Even without a NEXT_PUBLIC_ prefix, a secret leaks the moment a "use client" component imports a module that reads it — Next.js bundles the whole dependency for the browser. The server-only package is the guardrail: import it at the top of any module that touches secrets, and an accidental client import becomes a build error instead of a silent leak. This is the single highest-leverage line you can add to a secret-holding file.
3. Hardcoding the value
Pasting a literal sk_live_… or eyJ… token into source is the most direct leak — and the most permanent, because deleting the line later doesn't remove it from git history. Keep every secret in an untracked .env.local, reference it via process.env, and make sure .env* is in .gitignore. The hardcoded Supabase JWT is the same mistake in a Supabase-shaped disguise.
4. Git history (the leak that outlives the fix)
This is the one people forget. Every state your code has ever been in is recoverable from git. A key that was committed once — even if deleted in the very next commit — is still there for anyone who can clone the repo, and bots scrape public repos for exactly this. So "I removed it" is never the whole fix.
How do I get a secret out of my Next.js repo after it leaked?
Deleting the line is step zero, not the fix. Do all three:
- Rotate first. Revoke the exposed key at the provider (Supabase dashboard, Stripe, etc.) and issue a new one. Assume the old one is burned — treat it as already captured.
- Move it server-side. Store the new value in
.env.local(untracked), read it via unprefixedprocess.envinside aserver-onlymodule. - Purge history. Use
git filter-repoor BFG to remove the string from every past commit, then force-push. Deleting the file in a new commit is not enough.
A 30-second self-check
# Any secret-looking value behind a public prefix?
grep -rn "NEXT_PUBLIC_.*\(SECRET\|SERVICE_ROLE\|PRIVATE\|TOKEN\)" .
# Any secret-shaped literal committed in source?
grep -rnE "(sk_live_|eyJ|AKIA|ghp_)" --include="*.ts" --include="*.tsx" .
# Is your built bundle carrying anything it shouldn't?
grep -r "service_role" .next/ 2>/dev/null
Any hit is work to do. For a full picture, GuardLayer runs these checks — and the ones grep can't express, like distinguishing a public anon key from a real secret — on every push, with the fix inline.
FAQ
Which env vars are safe to prefix with NEXT_PUBLIC_?
Only values that are meant to be public: your site URL, the Supabase anon key, publishable (pk_) payment keys, and analytics tokens. Never a service_role key, a secret API key, a password, or a connection string.
Is .env.local safe by default?
Yes, as long as it's untracked. Next.js ignores .env*.local in its starter .gitignore, but confirm it — a committed .env.local puts every secret straight into history.
Does server-only actually hide the secret, or just warn?
It throws a build-time error if a client bundle tries to import the module, so the secret physically never reaches the browser. It's a hard guardrail, not a lint warning.
If a secret is only used in an API route or Server Action, am I safe?
Usually yes — those run on the server. The danger is the NEXT_PUBLIC_ prefix and accidental imports into "use client" files. Verify the variable name and that no client component imports the module.
Catch this before it ships — free
GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.