Notes
How NEXT_PUBLIC_ puts secrets in your Next.js client bundle
8 Oct 2026 · 1369 words
Next.js inlines NEXT_PUBLIC_ values into browser JS at build time. How to find a leaked key in .next/static, why service_role is a takeover, and how to rotate.
Next.js is explicit about this: environment variables are server-only unless you prefix them with NEXT_PUBLIC_. The prefix is not a naming convention. At next build, the bundler replaces process.env.NEXT_PUBLIC_* with a string literal and ships that string in the JavaScript the browser downloads.
That is the correct way to give the client your Supabase URL and publishable key. It is also the most common way a service_role key, a Stripe sk_live_, or an OpenAI key ends up in a public asset.
This post is about the mechanism, the one mistake that dumps your database, and a search you can run on the build output before you share a preview URL.
What Next.js actually inlines
From the Next.js environment-variable guide: non-public env vars stay in the Node.js environment. To make a value readable in the browser, Next.js inlines it at build time, replacing every process.env.NEXT_PUBLIC_FOO reference with the value from the environment that ran next build.
Two consequences follow.
The value is in the file. Anyone can View Source, open DevTools → Network, or curl your /_next/static/chunks/*.js files. There is no "hidden env" once the prefix is there.
The value is frozen at build time. Promoting a Docker image to another environment does not change NEXT_PUBLIC_*. If you need a runtime value on the server, read process.env.SECRET in a Server Component, Route Handler, or server action — no prefix.
Vercel will happily store SUPABASE_SERVICE_ROLE_KEY as a project env var. That is fine. The failure is importing it from a file marked "use client", or prefixing it so the bundler treats it as public.
What belongs in the browser
Supabase's current key names, from the API keys guide:
bash
# Safe to expose. Prefix per your framework. NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_… # Server only. No NEXT_PUBLIC_. No VITE_. No EXPO_PUBLIC_. SUPABASE_URL=https://YOUR_PROJECT.supabase.co SUPABASE_SECRET_KEY=sb_secret_…
The publishable key (sb_publishable_…) and the legacy anon JWT are the same idea: they identify the public app. Row Level Security decides what they can reach. See the RLS post for how to prove that.
The secret key (sb_secret_…) and the legacy service_role JWT are the same idea in the other direction. They map to the service_role Postgres role. That role has BYPASSRLS. It can read and write every table, list Storage, and call Auth admin endpoints. Official docs: never put one in a browser, a shipped application, or source control.
Legacy keys still work on existing projects. Creating the new keys does not revoke anon / service_role. You disable the old ones separately under Settings → API Keys. A tutorial that tells you to paste a long eyJ… token into createClient on the client is talking about the legacy anon key, which is acceptable. The same tutorial telling you to use the other eyJ… token — the one whose JWT payload has "role":"service_role" — is telling you to publish the master key.
Decode a suspect JWT without verifying it:
bash
python3 - <<'PY'
import base64, json, sys
token = sys.argv[1]
payload = token.split(".")[1] + "=="
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY
# usage: python3 snippet.py "$TOKEN"If role is service_role, rotate it. If role is anon, the key is the publishable one — keep it, and go fix RLS.
New secret keys are not JWTs. They look like sb_secret_…. They also do not belong in the client. Supabase rejects secret keys from a browser User-Agent with 401, which is a useful seatbelt and not a reason to ship one. An attacker curls without that header.
The service_role mistake, in the shape it actually takes
The leak is rarely NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=… in .env. It is usually one of these:
A shared lib/supabase.ts that calls createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!) and is imported from both a Server Component and a Client Component. The client import pulls the module into the browser graph. If the bundler can see the env access, you are one prefix or one inlined fallback away from shipping it.
A "just make it work" fallback:
ts
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
If the public var is missing on that preview, the server key becomes the one the client uses.
An AI-generated admin helper copied into app/page.tsx with "use client" still on the file. The model knows service_role bypasses RLS. It does not always know Next.js module graphs.
Hardcoding the key in a file under app/ or src/ because .env.local was annoying on the first deploy.
The fix is mechanical. Two clients, two files, no shared barrel that both sides import.
ts
// lib/supabase/client.ts — browser
import { createClient } from "@supabase/supabase-js";
export function createBrowserClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}ts
// lib/supabase/admin.ts — server only
import "server-only";
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}server-only makes a client import fail the build. Use it. Keep the admin client in Route Handlers, Server Actions, and cron-style routes. Do not pass it as a prop.
Stripe, OpenAI, AWS, and PEM private keys follow the same rule. If the browser does not need the value to render the first paint, it does not get a NEXT_PUBLIC_ prefix.
Find the key in the built JS
Do not trust "I didn't put it in a client file." Trust the bytes you are about to upload.
bash
npm run build rg -n "service_role|sb_secret_|sk_live_|sk-proj-|BEGIN .*PRIVATE KEY|AKIA" \ .next/static public
Also search the source, because a key in app/ or src/ will be in the next build even if this one looks clean:
bash
rg -n "SERVICE_ROLE|sb_secret_|sk_live_|NEXT_PUBLIC_.*SECRET" \ app src lib public
If rg is missing, grep -R works. You are looking for the literal key material, not the variable name. Next.js inlining means the built file contains sb_secret_… or eyJ… as a string, often with the name stripped.
A publishable key or a legacy anon JWT in .next/static is expected. A service_role JWT, an sb_secret_, a Stripe secret, or a PEM block is not.
For a site you cannot build locally, download what the CDN already serves:
bash
curl -sS https://your-app.vercel.app | rg -o 'https://[^"]+\.js' # then curl each chunk and search
That is the same corpus a preview scanner walks: HTML plus linked scripts.
Rotate, then prove the old prefix is gone
Supabase's leaked-key procedure is: fix the root cause first, then rotate. Do not mint a new secret and leave the import that leaked the old one.
- Move the privileged client behind
server-only. Remove anyNEXT_PUBLIC_prefix from secret material. Redeploy that commit. - Settings → API Keys: create a new secret key. Put it in Vercel as
SUPABASE_SECRET_KEY(Production and Preview). Update every server that used the old value. - Confirm those processes start and can still talk to Supabase.
- Delete the leaked secret key. That delete is not reversible. For a leaked legacy
service_roleJWT, deactivate the legacy keys in the same page — deactivation is reversible if you missed a worker. - Rebuild, redeploy, and run the
rgsearch on the new.next/static. Search for a unique prefix of the old key, not just the wordservice_role.
If the leak was a user JWT (role: authenticated) rather than an API key, revoke sessions under Authentication and consider rotating the JWT secret if it was a project-issued long-lived token. Then treat it like any other secret: it should not have been a string in source.
Stripe, OpenAI, and AWS have their own roll buttons. Same order: stop shipping the value, deploy, roll, re-scan the bundle.
What a preview scan already checks
hackymacky fetches the HTML and JavaScript a URL already serves. It looks for secret shapes: Stripe live/test secrets, OpenAI keys, AWS access key IDs, PEM private keys, generic api_key = "…" assignments, and JWT-shaped tokens. A JWT whose payload says service_role is critical. A JWT whose payload says anon is ignored — that is the publishable key. Partial Supabase config (a URL without a key, or the reverse) is called out so you can confirm nothing privileged is sitting next to it. A postgres:// or postgresql:// URL on a neon.tech host is the same class of leak — the password sits in the string and is redacted in the finding. Neon connection strings is that case on its own.
The preview does not need an account. With a detected project URL and publishable key it also probes REST / OpenAPI, RPC names, Storage, and write capability. What it shows you is the score, severity counts, and the top three findings with titles redacted. Claiming the domain unlocks the full finding list with fix snippets, Auth posture (settings, admin/users, identity tables, recover), daily rescans, history and diff, alerts, triage, the badge, and a report — so a later commit cannot quietly put a key back.
If you already have a production or preview URL, paste it on the homepage and read the titles. Then run rg on .next/static yourself. The scanner is a second pass, not a substitute for looking at the files you ship.