Your CI secret scanner is doing its job
A repository scanner that reports nothing is usually correct. The secret it cannot see does not exist until the build creates it.
"I already have secret scanning."
Good, and keep it. Committed credentials are a real failure mode with a long history, they are catastrophic when they happen, and a scanner watching every push is the correct response to them.
I want to be careful here, because the version of this argument that treats CI scanning as theatre is both wrong and easy to write. It is not theatre. It catches the key somebody pasted into a config file at eleven at night, the .env that got committed before the ignore rule existed, the token in a test fixture that was supposed to be temporary. Those are common and they are expensive.
The point is narrower than "your scanner is bad". It is that a green scan is an accurate statement about one thing, and most teams read it as a statement about a different thing.
"It scans every commit, including history."
It does, and that is more than most tools bother with. History scanning catches the credential that was committed and then removed in the next commit, which is the case people most often assume they have cleaned up.
Here is the awkward part. Everything in that sentence is about the repository, and the repository is a description of your application written by your team. It is not the application. On a modern frontend the file your users download is generated later, by a different process, on a different machine, and it contains things that were never in any commit.
The clearest example is the one nobody thinks of as a secret at the time:
const client = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!);
Scan that line. There is no credential in it. There is an identifier — a variable name — and no scanner should flag it, because flagging identifiers would make every scanner useless within a week. The .env holding the value is git-ignored and stays that way. Every scan passes, correctly, forever.
"If it is not in the repo, it is not in the app."
This is the load-bearing assumption, and it is the one that stopped being true.
A frontend build cannot pass an environment variable to a browser, because a browser has no environment to read. So bundlers substitute: at build time they replace the reference with the literal value, as a string, written into a JavaScript file that your CDN serves to everyone. The value comes from a .env on a CI runner or from a variable in your hosting dashboard — neither of which your repository has ever contained.
So the sequence is: the source is clean, the scan is green, the build creates the secret, and the CDN publishes it. There is no point in that chain where anything malfunctioned.
Your repository is clean. Your bundle is not. Those are different files.
The same shape appears elsewhere. A database policy's text is in your migrations; what an anonymous request actually gets back is not. Your refund code says what should happen; the ledger records what did. In each case the committed artifact is an honest description that has diverged from the running result.
"Then my CI is the wrong place for this."
No — it is the wrong place for this specific check, and the right place for several others.
Two things are worth separating. Scanning your build output in CI, after the bundle exists, catches a great deal and is a genuine improvement over scanning source alone. It is cheap and you should do it. What it still cannot tell you is what your CDN is serving right now, which is not always byte-identical once edge transforms and platform-injected scripts are involved, and it says nothing at all about the policy that changed yesterday or the refund issued this morning.
The general principle: a check can only examine an artifact that exists at the moment it runs. CI runs before deploy, so anything created by or after the deploy is outside its reach — not through any deficiency, but by position in the sequence.
A check can only see the artifact that exists when it runs.
"So what would actually have caught it?"
Fetching the file. That is genuinely the whole answer, and it is unsatisfying because it is so ordinary.
- Open your deployed JavaScript and search it for the credential formats you would recognise — a JWT prefix,
sk_live_,AKIA. Decode anything JWT-shaped and read its role claim. - Make the request a stranger would make against your own API, with the public key that is already in your bundle, and look at what comes back.
- Do it again after the next deploy, because the next deploy produces a different artifact and today's result does not carry forward.
Ten minutes for the first two. The third is the one that is easy to intend and hard to remember, which is the actual reason this became a category of tooling rather than a paragraph of advice.
None of that replaces your CI scanner. It answers the question your CI scanner was never positioned to answer.