Next.js + Supabase service_role in a client component — full anatomy
The quickest fix for a build error — adding NEXT_PUBLIC_ — is the one that inlines a bypass-everything key. Scan your Next.js app free.
In Next.js the same credential can be server-only or world-readable depending solely on which file imports it and which prefix satisfies the compiler. The service_role key makes that ambiguity expensive.
This page models substitution precisely for App Router projects, then gives rotation plus the route-handler refactor.
The mechanism, precisely
A server component reads process.env safely. Refactor the call into a client component and compilation fails to find the variable — unless it gains the public prefix, at which point:
- Next replaces process.env.NEXT_PUBLIC_* textually during build.
- The literal JWT lands in the client chunk beside your component code.
- Every visitor — and every crawler — receives working bypass credentials.
- RLS remains enabled and irrelevant: service_role exists to ignore it.
// app/components/Chat.tsx ("use client")
const key = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY;
const stripe = new Stripe(key); // inlined at build
// app/api/db/route.ts
import 'server-only';
export async function GET() {
const db = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!);
return Response.json(await db.from('orders').select());
}
Substitution is static and total: the literal replaces the reference everywhere the prefixed name appears, in every chunk, before anything is minified. Obfuscation changes what the string looks like; it does not change who can read it.
Variations of this mistake
- NEXT_PUBLIC_SUPABASE_SERVICE_ROLE named innocuously (“PUBLIC_DB_URL”) — obfuscation is not remediation.
- A shared lib imported by both trees; the client graph pulls the secret transitively.
- Templates typing all env as public by default — inherited from starter code nobody audited.
Where the value belongs instead
The working pattern keeps the call on a context that holds secrets without serving them, and gives the browser an endpoint to ask instead.
Verify what your build actually ships
Search the deployed JavaScript for eyJ in DevTools — thirty seconds, no tools beyond the browser. What you find is ground truth; everything earlier in the pipeline is intent.
The faster path is to let a machine do the fetching. KeyDrift downloads the same JavaScript a visitor gets — HTML, every referenced chunk including ones named only in the route manifest, and the server-streamed data frameworks inline into the document — and reports credentials with a masked prefix, a fingerprint, and the exact file they live in. Paste your deployed URL into the scanner; no account needed.
Why snapshots are not enough
It bears saying because it happens constantly: the fix holds until the next prompt that needs the query to return rows. Drift monitoring exists for precisely this — it diffs consecutive scans and pages you when a previously resolved finding reappears, naming the regression as a regression rather than repeating the first alert.
Why this keeps happening industry-wide
Zoom out and the pattern is bigger than one repo. AI-assisted output has outgrown review capacity everywhere at once, which means thousands of teams are making the same reasonable-looking tradeoffs in the same week. Next.js users are not uniquely exposed — they are typically exposed. The failure mode documented above is the modal outcome of velocity without verification, not evidence of carelessness.
How KeyDrift reports this exact finding
Report anatomy matters during incidents, so it is worth reading once calmly: masked string (never the live value — it ceases to exist outside the detection engine), salted fingerprint (trackable within your workspace, useless to strangers), chunk path (your starting point for a "git log -S" hunt), disposition (secret versus public-by-design), confidence (matches below 0.5 never reach the page at all).
Manual check, step by step
The full manual drill, for readers who want zero dependence on any tool: open the deployed site in a private window; launch DevTools → Sources; use Search-all-files (Ctrl/Cmd+Shift+F) for eyJ; then repeat for the other marker families — eyJ, sk_live_, sk-proj-, AKIA, postgres, BEGIN PRIVATE KEY. Decode anything JWT-shaped before reacting, and classify public-by-design formats as expected guests rather than intruders.
Close the loop with monitoring
If you take one operational step from this page, make it this: put the URL under continuous monitoring (free tier covers one project daily). The first scan tells you whether you have a problem today; the schedule tells you whether the problem comes back next month after someone re-adds the convenient line.
What the plans change
- Free $0 — 1 project · daily scans · email alerts · findings always visible.
- Indie $29/mo — 3 projects · hourly · Slack added · 80 chunks per scan.
- Team $89/mo — 15 projects · every 15 minutes · Discord + webhooks · 150 chunks.
- Growth — from $249/mo, quoted display-only until checkout ships.
The constant across every tier: plans limit how much is watched, never what a scan found. Visibility is structural, not promotional — asserted by tests over the entitlements model itself.
Common questions
Does middleware hide the key?
No — middleware runs on requests, not on bundle contents. The chunk still ships it.
NEXT_PUBLIC_ anon key is fine though?
Correct: anon/authenticated are public-by-design; only role claims decide severity.
Pages Router different?
Identical mechanism; the prefix is the boundary in both routers.
Run a free scan at keydrift.dev/scan — paste a URL or the bundle source itself, no account. Findings arrive masked, with the exact chunk they live in.
Related
KeyDrift is an independent product and is not affiliated with, endorsed by, or sponsored by Next.js. The name is referenced descriptively.