KeyDrift
Free scan
criticalNext.js·Supabase JWT

NEXT_PUBLIC_ leaked your supabase jwt into the App Router bundle

Full read and write access to every table, bypassing Row Level Security.

Why Next.js does this

A server component can read `process.env.STRIPE_SECRET_KEY` safely. Move that same line into a client component and the build fails to find it, so the quickest fix — rename it with `NEXT_PUBLIC_` — is also the one that inlines it into the JavaScript every visitor downloads.

Confirm it first

Before rotating anything, check whether the key is actually being served. Paste your deployed URL — KeyDrift downloads the same JavaScript a visitor gets and tells you what is in it.

No account. Read-only — the scanner only ever issues GET requests, and never stores a key: findings carry a masked prefix and a fingerprint.

Rotate the key

Do this before changing any code. The key has been served to browsers, cached by CDNs and very likely scraped already — removing it from the source does not un-publish it.

  1. 1Rotate the key in Project Settings → API. The old one stops working immediately.
  2. 2Move whatever needed it into an Edge Function or a server route, and keep only the anon key in the browser.
  3. 3Check Row Level Security is enabled on every table — a leaked service_role key bypasses it, so RLS is what limits the damage from the next one.
Open the revocation page

Move the call to a server

The replacement key must not follow the old one into the bundle, which means the code that uses it cannot live in the browser. Any variable named NEXT_PUBLIC_ is inlined at build time by design — the prefix is the mechanism, not a mistake.

Before — shipped to the browser

// app/components/Chat.tsx  ("use client")
// NEXT_PUBLIC_ inlines this into the browser bundle.
const key = process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY;
const result = await createClient(url, key).from(table).select();

After — stays on a route handler or server action

// app/api/admin/route.ts  — runs on the server only
import 'server-only';

export async function POST(request: Request) {
  const key = process.env.SUPABASE_SERVICE_ROLE_KEY; // no NEXT_PUBLIC_ prefix
  const result = await createClient(url, key).from(table).select();
  return Response.json(result);
}

// app/components/Chat.tsx  ("use client")
const result = await fetch('/api/admin', { method: 'POST' }).then((r) => r.json());

How KeyDrift detects it

Matches any three-segment JWT, then decodes the payload — without verifying the signature, because the question is what the token claims to be, not whether it is valid. A `role` claim of `service_role` is critical; `anon` and `authenticated` belong in a browser and are reported as informational. A token issued by `supabase-demo` is the local development default that `supabase start` generates identically on every machine, so it is not treated as a leak.

It will happen again

A server component can read `process. That has not changed because you fixed this one file — the next feature request produces the same shape of code. Continuous monitoring re-scans every deploy and tells you the moment a key comes back.