Skip to content
KeyDrift
Scan for free
All posts

How keys migrate from .env into source in AI workflows

The paste-and-iterate loop traced step by step: how assistants move secrets from environment files into bundles through diffs nobody thinks to question.

KeyDrift12 min read

How keys migrate from .env into source in AI workflows

A secret can travel from a correctly-ignored .env file into a public client bundle without anyone making a mistake. These are the exact mechanics — a sequence of small, locally-correct changes that no review process flags.

Here is the uncomfortable part of modern secret leakage: it usually involves no negligence to find. The .env file was created correctly. Git ignores it. Platform secrets are configured properly. And yet the deployed bundle serves the key to every visitor. The migration happened afterward, in small steps, each individually defensible — most often inside an AI-assisted workflow where each step looked like progress.

This article traces both migration paths step by step with synthetic examples, explains why every existing control (review, linting, commit-time scanning) misses this class structurally, and describes what breaks the loop. The machinery being exploited — prefix substitution at build time — is documented fully in the framework public-env guide; here we follow the values.

Path one: the placeholder swap

The most direct route starts with a context gap. An assistant writing integration code cannot see your actual .env values — good assistants don't read secret files, and sandboxed ones can't. So it emits a placeholder:

// src/services/ai.ts — generated
const OPENAI_API_KEY = "YOUR_OPENAI_API_KEY"; // ← placeholder the assistant wrote

export async function summarize(text: string) {
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    headers: { Authorization: `Bearer ${OPENAI_API_KEY}` },
    method: "POST",
    body: JSON.stringify({ /* ... */ }),
  });
}

The code runs, fails auth, returns an error the developer sees in the UI. The instruction loop does what iteration loops do: remove the obstacle. The developer pastes the real key over the placeholder — thirty seconds, feature works, momentum preserved.

Every example key in this article is synthetic; the real one that replaces it in this scenario is indistinguishable from surrounding code. The commit diff contains the key, which means commit-time scanning should catch this path — push protection and pre-commit scanners are the controls that work here. When they're absent, the migration completes silently into git history (with all the permanence that implies).

Path one is the older story. AI workflows add a twist: assistants asked to "make it work" may complete the swap themselves when the key sits anywhere in readable context — an open editor tab, a terminal buffer, a config file loaded into the conversation. The developer never even performs the fatal edit; they just approve the diff.

Path two: the prefix rename — no secret ever touches git

The second path is subtler and dominates in frontend-heavy projects, because at no point does the secret appear anywhere a scanner looks.

It begins identically: generated code references an environment variable, but in a module destined for the browser:

// src/lib/chat.ts — bundled for the client
const res = await fetch("/api/chat", {
  headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
});

The build resolves this expression in client context. Framework behavior varies by convention: unprefixed variables become undefined in Next.js client builds; Vite leaves non-VITE_ keys off import.meta.env entirely. Either way the result is identical — the deployed feature breaks at runtime, sending Bearer undefined to the API.

Now the repair, and watch how small it is:

- headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
+ headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}` },

One line. The variable name gains the framework's public-prefix marker; the value moves from .env (still ignored by git, still correct) through the build environment into the compiled bundle via documented substitution. Reviewing the diff, what does anyone see? A green line. A plausible variable name following a convention used elsewhere in the codebase. Nothing resembling a secret appears anywhere in the repository — because the mechanism that copies the value operates at build time, after git's jurisdiction ends.

The .env side changes too:

- OPENAI_API_KEY=sk-proj-...
+ NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-...

Also one line, also in an ignored file. The repository remains perfectly clean at every commit. The deployed JavaScript carries the live key. This is why artifact-level scanning exists: for path-two migrations, there is nothing to detect in source control, ever (what the scanner sees). Every key shown is a synthetic placeholder; the real failure ships the genuine value with this exact shape.

The compounding effect: conventions emerge by imitation

Both paths get worse in AI-assisted repos through a mechanism the individual diffs never reveal: assistants learn local conventions from the codebase itself.

When one module successfully reads a prefixed variable, that pattern becomes precedent. The next feature asking for API access gets generated with the same shape — not because the assistant misbehaves, but because consistency with observed code is exactly what it's trained to produce. Within weeks a repo can hold five prefixed variables, three of them secrets, each introduced by an individually-reasonable change, none reviewed as a security decision. The leak spreads by inheritance.

This also means cleanup isn't a search task. Removing one variable while four imitators remain guarantees regeneration. The fix has to target the convention, which is why prevention below emphasizes structural signals (server-only imports, naming contracts) over one-off corrections.

Why the standard controls miss it

Running the failure through each familiar defense:

ControlPath one (paste)Path two (prefix)
Code reviewCatches it if reviewers read for secrets — usually lost in feature diffsPasses: no secret-shaped string exists in the diff
Commit-time scanningWorks (key literal enters git)Nothing to match: repo never contains the value
Push protectionWorks for known formatsNothing to match
Linting/type-checkingValid syntax, valid typesValid syntax; prefixed names resolve at build
.env hygieneIrrelevant — the file behavesIrrelevant — the file behaves
Post-deploy artifact scanCatchesCatches

The table's right column is the point of this article: path two defeats every source-oriented control by construction, because the leak's evidence lives only in build output. Controls aren't being used wrongly; they're aimed upstream of where the value crosses the line.

Breaking the loop

Interventions matched to each stage of the loop:

  1. Starve path one at the source: never place real values in editor-visible locations during AI sessions; inject them via platform env vars the assistant references but never sees. Assistants should write process.env.MY_KEY and stop there — treat a completed literal as a defect to reject in review.
  2. Make the safe path cheaper than the unsafe one: provide ready-made server routes/proxy handlers so "call the provider" naturally lands server-side. Full-stack frameworks make this a ten-line template; when the server-side option is the fastest option, imitation works for you.
  3. Structural guards over vigilance: server-only import markers (Next.js's server-only package throws at build time when server modules leak into client graphs) and naming contracts (SERVER_ vs PUBLIC_ prefixes internally) convert drift into loud failures instead of silent successes.
  4. Scan the artifact, continuously: the only detector positioned where path-two leaks become visible. Run it pre-deploy in CI against built output and post-deploy against the served site — scheduled monitoring covers the regenerations that arrive weeks later via imitation (scanning versus monitoring).
  5. Treat prefixed-variable additions as security events: whatever review process exists — human or self-review checklist — a new NEXT_PUBLIC_*/VITE_* variable deserves the same scrutiny as a dependency addition. The rules reference encodes the expected-public set so exceptions stand out.

None of these require abandoning AI-assisted development. They require recognizing that the loop optimizes for working features, and building the one feedback channel — artifact inspection — that reports on what optimization actually produced.

Three migration traces, annotated

Abstract mechanics land harder as concrete diffs. Each trace below uses synthetic values and shows exactly what the reviewing eye saw versus what the build produced.

Trace one — Stripe checkout into a client component. The feature request: "add the upgrade button to the pricing page." The assistant writes it where the page lives:

+ // app/pricing/UpgradeButton.tsx  ('use client')
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
+ await stripe.checkout.sessions.create({ ... });

The build fails — STRIPE_SECRET_KEY resolves undefined in client bundles. The repair that compiles:

- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
+ const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY);

Review sees two green lines and a plausible prefix. Build output now contains a live payment credential inside a chunk every visitor downloads (substitution mechanics). The architecturally correct move — creating the session in a route handler and passing the client only a session ID — differs by an import path, which is precisely why imitation beats instruction in this loop.

Trace two — Supabase service-role in a Vite SPA. Scaffolding copied both keys into .env:

VITE_SUPABASE_URL=https://xyz.supabase.co
VITE_SUPABASE_KEY=sb_secret_FAKE0000000000000000000   # secret-tier, prefixed anyway

No dramatic moment exists here at all — the convention was applied to the secret from birth, likely because scaffolding templates name variables this way and nobody revisited the semantics. The deployed bundle carries full-database-access credentials; the repository has never contained anything scannable. Only artifact-level inspection observes the result (what belongs in bundles).

Trace three — the debugging promotion. A Create React App integration fails in staging: REACT_APP_PAYMENT_TOKEN missing. Someone adds it to the staging environment file, watches it work, commits nothing — but the platform environment persists the variable for production builds too. Weeks later the token rotates on a schedule; nobody re-checks whether the staging value was ever removed from the platform. The leak here isn't a diff at all — it's dashboard state outliving memory, discoverable only by auditing what builds actually receive.

Across all three traces, note what incident reports would later call the cause: not one decision, but each step being locally reasonable. That's why prevention targets structure (server-only boundaries, prefix allowlists) rather than vigilance — structure survives locally-reasonable steps; vigilance doesn't.

A CI guard for the boundary

Structure can be enforced mechanically at review time. Two checks cover most of the migration space:

# .github/workflows/boundary-guard.yml
name: boundary-guard
on: [pull_request]
jobs:
  guard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 1. Server-shaped names must not appear in client directories:
      - name: Server env names in client code
        run: |
          ! grep -rnE 'process\.env\.(STRIPE_SECRET|SUPABASE_SERVICE|OPENAI_API)' \
            app/ components/ src/client/ --include='*.ts' --include='*.tsx'

      # 2. Whatever the build produced must be free of credential formats:
      - name: Built output scan
        run: |
          npm ci && npm run build
          npx --yes gitleaks detect --no-git --source ./dist --redact

The first check encodes the naming contract as CI failure — a prefixed rename now breaks the pipeline visibly instead of publishing silently. The second catches everything naming misses, including platform-injected surprises. Neither check is sophisticated; both convert the migration loop's silent success into loud failure, which is the entire game. The same guard exists in pre-commit form for teams wanting boundary checks at author time — milliseconds against staged files:

# .husky/pre-commit (addition): fail when client paths ADD server-name refs
if git diff --cached --name-only | grep -qE '^(app|components|src/client)/'; then
  if git diff --cached -U0 | grep -E '^\+.*process\.env\.(STRIPE_SECRET|SUPABASE_SERVICE|OPENAI_API)'; then
    echo "commit blocked: server env name referenced in client path" >&2
    exit 1
  fi
fi

Local checks stay advisory by nature — the gate hierarchy covers why — but they shift discovery from CI minutes to editor seconds, which is where iteration loops actually live.

Tune both checks against your stack's naming reality: the name list above is illustrative, and every codebase grows its own server-shaped conventions (rules worth encoding). What must not vary is the principle — the boundary between server-held and client-shipped values is enforceable text, and enforcing it mechanically beats asking tired reviewers to remember which prefixes publish.

Assistant-level guardrails help at the margin and belong in shared instruction files. A compact version teams can paste into their agent configuration:

- Environment variables referenced in client-bound code MUST come from the
  approved public-prefix allowlist. Never invent public-prefixed names.
- Never inline literal credentials from context, transcripts, or config
  files you can read. Reference process.env names instead.
- If a build fails resolving a server variable in client code, STOP and
  propose a server route — do not repair by renaming the variable.

Instructions decay under iteration pressure, as established — which is why they sit third in defense depth behind structural guards and artifact scanning. But they cost nothing, they teach every future session the repo's jurisdiction rules, and they occasionally produce the best outcome available: the assistant proposing the server route unprompted, having learned that the rename path is forbidden territory.

A review card for pull requests touching environment names compresses the whole detection problem into four questions reviewers can actually hold:

  • Does this PR introduce, rename, or remove any environment variable?
  • If introduced: does its name carry a client-publicity prefix, and is the value public-tier (tier table)?
  • If renamed: did anything change jurisdiction — server-held becoming client-shipped?
  • If removed: which platform stores still hold the old name?

Card-form beats prose-form in review contexts because it fits in the PR template itself, requiring zero memory of the underlying mechanics. Most migrations fail open review simply because nobody asked the rename question — the diff showed one green line, and green lines read as progress unless a checklist interrupts.

The traces share one more property worth naming: each was reversible at every step until deployment. Rotating after discovery works regardless of how long the value sat in git; bundles can be rebuilt; platform variables deleted. Irreversibility enters only when third parties collect — which is why detection latency, not prevention perfection, dominates outcomes here, and why artifact-side monitoring pairs so naturally with source-side discipline (the drift argument).

FAQ

Our .env never entered git. Are we saying a leak is still possible? Yes — that's path two exactly. Git hygiene governs source; bundlers govern output; the prefix convention moves values between those jurisdictions at build time. Only artifact inspection observes the result.

Can't we just tell the assistant to never use public prefixes? Instructions help at the margins and decay under iteration pressure: when a feature fails without the prefix, the instruction competes with the goal. Structural guards (item 3) enforce the rule mechanically; prompts don't.

Where do we look right now, today, in our app? View-source on your deployed site, search the JS for your providers' secret formats — the five-minute procedure in the browser-bundle guide. Or automate it: the free scan does the same sweep across every chunk.

If we clean up, will it stay clean? Only with the continuous piece. New modules, refactors, and regenerated components re-roll the dice each build — the same drift dynamics that affect every pipeline-dependent control (why point-in-time expires).


The migration is quiet by design; the check doesn't have to be. Run KeyDrift's free scan — minutes, no signup — and see whether any key has already made the journey.

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.