KeyDrift
Free scan
All posts

Clean repo, leaky build: the definitive guide

How secrets that never appear in git end up in production JavaScript: bundler inlining, source maps, and deploy artifacts — with a demo you can run.

KeyDrift13 min read

Clean repo, leaky build: the definitive guide

If your repository is clean but your deployed JavaScript contains a secret key, this article explains how that happens mechanically — bundler substitution, source maps, artifacts, logs — and shows you a demonstration you can reproduce in five minutes with a synthetic key.

The premise of most secret-scanning advice is that secrets live in source code. Keep them out of git, scan git for accidents, and you are done. That model was already incomplete before AI-assisted development, and it is badly mismatched to how modern web apps are built. A web application's repository is an intermediate representation. The thing users actually execute is the build output: minified bundles, copied assets, uploaded packages, serverless archives. If a key reaches that output, it is public, regardless of what git status says.

This is the failure mode KeyDrift was built around: clean repo, leaky build. The repository passes every scanner. The deployed app hands a live credential to anyone who opens DevTools. This guide covers the mechanism in detail — where values come from, where they get substituted into output, which surfaces beyond the main bundle carry them, why repository scanners structurally cannot catch it, and what detection has to look like instead.

What "clean repo, leaky build" means

A repository is clean when no secret appears anywhere in its history: not in commits, not in branches, not in tags. A build is leaky when a secret appears anywhere in its output: not just the main bundle, but source maps, chunk files, serverless function archives, Docker layers, and CI artifacts.

Both statements can be true of the same project on the same day. There is no contradiction because the two states describe different objects. Git stores what developers wrote. The build produces what browsers download. Secrets enter the second object through mechanisms that operate after git's job is done:

  • Environment variables injected at build time by your hosting platform.
  • Framework conventions that promise to inline certain variables into client code.
  • Bundler features whose purpose is compile-time constant replacement.
  • Source maps generated from pre-build source.
  • Artifacts and caches assembled from working directories that hold more than git tracks.

None of these mechanisms read from git. They read from your filesystem and environment at build time. A scanner that only reads git is auditing the wrong artifact for these paths.

How an environment value becomes bundle content

The dominant mechanism deserves its own walkthrough, because it is not a bug in any tool. It is the documented, intended behavior of nearly every frontend framework.

Server-side code can keep a secret in memory. Browser-side code cannot: whatever value the browser needs must be transmitted to the browser, which means it is readable by anyone with network access to your site. Frameworks resolve this tension with a convention: prefix an environment variable's name in a particular way, and the framework treats it as safe for the client and makes it available to browser code by substituting the literal value into the bundle at build time.

FrameworkPublic-by-prefix variableMechanismDocumented behavior
Next.jsNEXT_PUBLIC_*Compile-time text substitutionValues are inlined into JS sent to the browser (Next.js docs)
Vite (and derivatives)VITE_*Static replacement of import.meta.env.*Only VITE_-prefixed vars reach client builds (Vite docs)
Create React AppREACT_APP_*Compile-time substitution via webpack DefinePluginEmbedded during npm run build (CRA docs)
ExpoEXPO_PUBLIC_*Inlined into the bundle at build timeAvailable in client code as literals (Expo docs)
AstroPUBLIC_*Static replacementIncluded in client-side bundles (Astro docs)
NuxtNUXT_PUBLIC_* / runtime configServed to the client at runtimePublic config is serialized into the page payload (Nuxt docs)

The mechanics matter. In Next.js, when the compiler encounters process.env.NEXT_PUBLIC_STRIPE_KEY in a module included in a client bundle, it replaces the entire expression with the string literal from the build environment. Not a reference — the actual characters of the value, embedded in the JavaScript. In Vite, import.meta.env.VITE_API_URL undergoes the same treatment. Create React App does it through webpack's DefinePlugin, which performs identical expression-level substitution.

Three properties follow, and each one defeats a common assumption:

  1. The value is fixed at build time. Changing the environment variable later changes nothing about what is already deployed. You must rebuild.
  2. The substitution is unconditional. If the variable exists in the build environment, its value ships. The bundler does not evaluate whether the value looks like a secret.
  3. Git is not involved at any point. The .env file is correctly ignored, the platform env var store is configured correctly, and the pipeline works exactly as documented. The leak is the intended outcome of the convention being used as designed.

There is also the quieter variant: a developer or an AI assistant writes const KEY = process.env.OPENAI_API_KEY in a component that gets bundled for the browser. Without the public prefix, the expression is replaced with undefined at build time — the code breaks, the developer sees the breakage, and the fastest fix is renaming the variable with the magic prefix. The rename compiles, the feature works in preview, and the literal value is now in the bundle. We cover this loop separately in how keys migrate from .env into source and in the definitive guide to public-by-prefix conventions.

A reproducible demonstration with synthetic keys

You can watch this happen locally in five minutes with esbuild, which exposes the same substitution primitive the frameworks use. Every key below is a synthetic placeholder in a provider's format — it authenticates against nothing and exists only so you can grep for it.

Create the project:

mkdir leak-demo && cd leak-demo
npm init -y
npm install --save-dev esbuild

# The .gitignore any real project would have: build output and deps stay untracked.
printf 'dist/\nnode_modules/\n' > .gitignore

Add src/app.js — a stand-in for any module that ends up in a browser bundle:

// src/app.js
async function chargeCustomer(amount) {
  const response = await fetch("https://api.stripe.com/v1/charges", {
    method: "POST",
    headers: {
      // No literal here — the bundler substitutes this at build time.
      Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    },
    body: new URLSearchParams({ amount, currency: "usd" }),
  });
  return response.json();
}

chargeCustomer(2000);

Add build.mjs, which reads the secret from the build environment and substitutes it, exactly as framework tooling would. Note what is not here: no key literal. The value arrives from the environment at build time, the same way a hosting platform injects it.

// build.mjs
import * as esbuild from "esbuild";

await esbuild.build({
  entryPoints: ["src/app.js"],
  bundle: true,
  define: {
    "process.env.STRIPE_SECRET_KEY": JSON.stringify(process.env.STRIPE_SECRET_KEY),
  },
  outfile: "dist/app.js",
});

Run the build with the secret in the environment, then search the output:

# Synthetic placeholder in Stripe test-key format. Not a real key.
export STRIPE_SECRET_KEY=sk_test_51SYNTHETICPLACEHOLDERdoNOTship000000

node build.mjs
grep -o "sk_test_[A-Za-z0-9]*" dist/app.js

Output:

sk_test_51SYNTHETICPLACEHOLDERdoNOTship000000

The synthetic key is in the bundle. On Windows PowerShell, set the variable with $env:STRIPE_SECRET_KEY = "sk_test_51SYNTHETICPLACEHOLDERdoNOTship000000" and search with Select-String -Path dist\app.js -Pattern "sk_test_".

Now confirm the repository side:

git init && git add -A && git commit -m "init"
git log -p | grep sk_test_
echo $?

The commit contains no trace of the key — exit code 1, no matches. Only .gitignore, package.json, package-lock.json, src/app.js, and build.mjs were committed, and none of them holds the value: it lived in the environment, and dist/ is ignored. The repository is clean. dist/app.js is leaky. If you deploy dist/ right now, every visitor downloads the key. Run the same check against your real production bundle — view-source: on your site, then search for the prefixes below — and you have performed, manually, the core of what KeyDrift automates:

Prefix familyBelongs toExpected in a client bundle?
sk_live_, rk_live_, sk_test_, rk_test_Stripe secret/restricted keysNever (Stripe keys doc)
sb_secret_ / legacy service_role JWTsSupabase elevated-access keysNever (Supabase API-keys doc)
sk-proj-, sk-svcacct-, sk-ant-api03-OpenAI / Anthropic API keysNever
AKIA… access key IDsAWS IAM credentialsNever
pk_live_, sb_publishable_, AIza… web keysPublishable / public-by-design keysYes, expected

That last row is the nuance that trips up naive scanning, and it is covered in the scanner rules documentation: a client bundle is supposed to contain some keys. Detection without that context drowns you in false positives.

Where else builds carry secrets

The main bundle gets the attention because it is the largest surface, but a production deployment includes several other places a build-time value lands.

Source maps. If your build generates .map files and they ship alongside the bundles, anyone can reconstruct something close to your original source — including lines where a key was inlined before minification ever mangled it. Some teams upload maps to error-tracking services and serve them privately; others leave them in the static directory by oversight. Check whether https://your-site.example/_next/static/**/*.js.map (or the equivalent for your stack) returns 200.

Serverless function archives. Platforms zip your functions and their dependencies at deploy time. A stray .env copied into a serverless package — because a Dockerfile did COPY . . or a packaging script globed too wide — puts the file inside an artifact whose contents are less inspected than the repo itself.

Docker images. COPY . . plus a forgotten .env bakes the file into a layer. Even a later RUN rm .env leaves it in the earlier layer, because layers are additive. Registry pull access then equals key access.

CI logs and artifacts. Build logs echo environment variables under verbose flags and debug steps; artifacts and caches snapshot working directories. Retention windows mean the exposure persists long after the deploy that created it. This surface has its own guide: build logs and CI artifacts.

Each of these surfaces shares the property that matters here: they are produced by the build, outside git, and distributed by deploying. None of them is visible to a repository scanner.

Why repository scanners cannot see it

Repository scanners — push protection, pre-commit hooks, CI jobs that run gitleaks over the checkout — solve a real problem: developers pasting keys into source files, or committing them by accident. When that happens, they are the right tool. The gap is categorical, not a tuning issue.

A repo scanner's input is the version-control state: the current tree, or history, or a diff. The leaky artifact is downstream of all three. Between a clean tree and the deployed bundle sit the build environment (which holds secrets the repo never saw), the bundler (which performs substitutions), the packagers (zips, images, chunks), and the host (which serves the result). Each stage can introduce a secret that provably was not present in the input.

You can verify the categorical claim on the demo above: run any git-aware scanner over the leak-demo repository and it reports nothing, while grep finds the synthetic key in dist/app.js. The scanner is correct about its input. Its input is not the artifact your users download.

This is why "we run secret scanning in CI" and "our repo is clean" are answers to a question the browser never asks. The browser asks: what bytes does this URL return? Detection that means anything has to eventually read those bytes.

What clean actually requires

Once you accept that the artifact is the audit target, the requirements follow mechanically.

Baseline: know what is supposed to be there. Client bundles legitimately contain publishable keys — Stripe's pk_live_, Supabase's sb_publishable_, Firebase web configuration. A scanner that flags those has told you your working app is on fire, and you will stop listening. Useful detection distinguishes public-by-design keys from elevated-privilege ones; the privilege model per provider is laid out in the key-class guides, starting with Stripe and Supabase.

Scan the shipped artifact, not just the source. The check that finds this class of leak is: fetch the JavaScript your site serves, apply detectors tuned to both provider formats and to known-public formats, and report only what should not be there. It runs against a URL — the same URL your users load — and requires no repository access at all.

Rescan when drift happens. A point-in-time scan expires at your next deploy. Builds re-run, dependencies update, someone re-adds a variable with the wrong prefix, an assistant regenerates a component. Every deploy re-rolls the dice, which is why continuous monitoring rides deploys and a schedule rather than running once. The reasoning is laid out in scanning versus monitoring.

Rotate on finding, not on hope. A key in a served bundle must be treated as disclosed to the entire internet the moment the deploy went out. Removal-from-bundle is not remediation; rotation is. Provider-specific procedures live in the fix guides.

KeyDrift implements exactly this loop: a scan of what your deployed site actually serves, then continuous monitoring that re-checks on deploys and on schedule. The founding observation — clean repo, leaky build — is not a slogan about carelessness. It is a description of how the toolchain behaves when everyone involved follows the documented instructions.

FAQ

My .env is in .gitignore and my repo scans clean. Am I done? No. Those facts establish nothing about your build output. The .env values that get a public-by-prefix name are copied into the bundle at build time; values referenced from client-included modules may be inlined or may break silently depending on framework. The only way to know is to inspect what your deployed site serves.

Doesn't minification hide keys? Minification renames identifiers and removes whitespace. String literals — which is what inlined keys are — pass through untouched, possibly concatenated but fully recoverable. Obfuscation of strings is not a feature of standard bundlers, and treating it as protection would be guessing.

I found a test-mode key (sk_test_, sandbox keys) in my bundle. Is that urgent? Lower stakes than a live key, since test data is isolated, but still worth removing: test environments often contain realistic fake customer data, and the habit of shipping test keys is one rename away from shipping live ones. Rotate it and move it behind a server route.

How often should I check my own bundle? Manually, whenever you ship anything new; mechanically, on every deploy. The manual check above takes five minutes and catches the common cases. Deploy-triggered monitoring catches the rest — including regressions reintroduced weeks later.

What do I do first if the scan finds something? Treat the key as compromised regardless of how long it has been there: rotate it at the provider first, then fix the build path that put it there, then redeploy and confirm the new bundle is clean. The ordering matters, and the incident-response runbook walks it step by step.


Everything above reduces to one checkable claim: the JavaScript your site serves either matches your intent or it doesn't, and you can find out which in minutes. Run the free scan at keydrift.dev/scan — no signup required — and see exactly what your deployed bundle is handing to every visitor.

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.