Skip to content
KeyDrift
Scan for free
All posts

Your AI just put a secret key in the browser

Six moments between a failing query and a credential on a CDN. At every one of them, every tool involved reported success.

KeyDrift5 min read
secretsai

The mechanism behind this — build-time substitution, and why a git-ignored .env does nothing to prevent it — is covered in your .env file is not the problem. This post is the other half: the evidence trail. Six moments, in order, and what each system involved reported at the time.

The short version is that all of them reported success, because all of them were working correctly.

Step 1 — the query returns an empty array

A developer is adding an admin screen that lists every order. The query runs and comes back with nothing.

const { data, error } = await supabase.from('orders').select('*');
// data: []   error: null

What the tooling reported: no error. error is null, the request returned 200, and the console is clean. Row-level security did exactly its job — an anonymous or non-owner caller is not entitled to those rows, so zero rows survived policy evaluation. The empty array is the correct answer to the question that was asked.

But an empty array with no error is indistinguishable, from the developer's chair, from "there is no data". So the diagnosis begins in the wrong place.

Step 2 — the assistant reaches for the key that works

The developer asks their assistant why the query returns nothing and how to fix it. The assistant is being asked to make a query return rows, and there is a credential that makes any query return rows.

const supabase = createClient(url, SERVICE_ROLE_KEY);

What the tooling reported: the query now returns every order. The screen renders. The feature is finished and it took ninety seconds.

This step is where the whole thing turns, and it is worth being precise about why it is not incompetence. The service-role key bypasses row-level security by design — that is its purpose, and using it on a server is completely normal. The assistant took the shortest path to the stated goal, which is what it is for. Nothing in the request said "and keep this credential off the client", because nobody thinks to say that about a key they have not yet decided where to put.

Step 3 — the value needs a prefix to exist in the browser

The admin screen is a client component. process.env.SUPABASE_SERVICE_ROLE_KEY is undefined there, because there is no environment in a browser. The fix that makes it defined is a prefix.

NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EXAMPLE_PAYLOAD_NOT_A_REAL_KEY.EXAMPLE_SIGNATURE

What the tooling reported: the variable is now defined at runtime and the screen works locally. No warning is emitted, because none is warranted — the prefix is a build instruction, it was used correctly, and the framework has no way to know that the value behind it is one that should never be published.

Step 4 — the diff contains a variable name

The pull request goes up. Here is the entire security-relevant content of it:

-const supabase = createClient(url, anonKey);
+const supabase = createClient(
+  url,
+  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!,
+);

What the tooling reported: the secret scanner passed. It was right to. There is no credential in this diff — there is an identifier. No high-entropy string, no recognisable prefix, nothing to match on. The .env file was never committed and remains correctly git-ignored. Every scan of the repository, at every commit, for the entire life of this project, will report exactly nothing, and every one of those reports will be accurate.

Step 5 — review passes, because nothing in the source is wrong

A reviewer reads the diff. It is four lines. The variable name is longer than the one it replaces, and long environment-variable names are unremarkable in a Next.js project where half of them start with NEXT_PUBLIC_.

What the tooling reported: approved. Tests pass — the feature works, so the test asserting that the admin screen lists orders is green. Type checking passes. The linter has no rule for this and could not sensibly have one, because prefixing an environment variable is a legitimate operation performed dozens of times in any normal codebase.

The generated diff contains a variable name, not a secret. Nothing looks wrong in review, because in the source nothing is wrong.

This is the moment where the last human opportunity to catch it passes, and it passes without anything for the human to catch.

Step 6 — the CDN serves it

The merge triggers a build. The bundler performs a textual substitution, writing the literal token into a JavaScript chunk. The chunk is uploaded and cached at the edge.

curl -s https://example-app.test/_next/static/chunks/4823-a91f.js | grep -o 'eyJ[A-Za-z0-9_-]\{20\}'

What the tooling reported: build completed, deploy succeeded, site healthy. Uptime monitoring is green because the site is up. Error tracking is quiet because nothing threw. The deploy notification in Slack has a green tick on it.

And a token whose role claim reads service_role is now in a file served to every visitor. Anyone who opens developer tools can read it, and it bypasses every policy in the database — not just the ones on orders.

Three details make step 6 worse than it first appears. The value is not fixed by removing it: anyone who loaded that page already has it. Caches and archives can continue serving the old chunk after you redeploy. And the token is a JWT, which means its payload can be decoded without any secret at all — the role claim is right there in plain base64 for anyone who bothers to look, so nothing about identifying it requires guesswork.

The only correct first move is to rotate at the provider, immediately, before touching the code. Everything else is second.

If you want to know whether this has happened to you, the check is the same request as the curl above, run against your own domain. It takes two minutes and it is worth doing before you finish reading anything else.

Run a free scan

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.