Skip to content
KeyDrift
Scan for free
All posts

Browser bundles: how secrets end up in JavaScript your users download

The exact mechanisms — framework prefixes, compile-time substitution, source maps — plus a five-minute procedure to audit your own bundle.

KeyDrift13 min read

Browser bundles: how secrets end up in JavaScript your users download

Every web app hands its JavaScript to anyone who asks for it. This explains how configuration becomes bundle content through documented framework behavior, then gives you a five-minute procedure to see exactly what your own site hands out.

A browser cannot hold a secret. It executes whatever JavaScript arrives over the network, and anything inside that JavaScript is readable by every user, extension, and proxy on the path. This is not a vulnerability class to be patched; it is the physics of the platform. Yet deployed bundles contain elevated-privilege credentials often enough that checking for them is worth making routine. They arrive through a small number of well-defined mechanisms, all of them documented features of the toolchain rather than accidents of carelessness.

This guide covers each mechanism precisely, shows what substitution looks like in real output, explains when source maps compound the problem, and walks through auditing your own site in five minutes. The companion piece on the broader leak surface places bundles among the other post-git escape hatches; this article stays on the browser itself.

The conventions that publish configuration

Every major frontend framework faces the same problem: application code running in the browser needs some deployment-specific values — API endpoints, feature flags, publishable keys — and the natural place developers put values is environment variables. Since browsers never see process environments, frameworks bridge the gap with naming conventions: variables whose names carry a specific prefix are treated as intended for the client, and their values are embedded into the client build.

FrameworkClient-visible variableHow it reaches the userDocumented reference
Next.jsNEXT_PUBLIC_*Literal value substituted into the bundle at build timeEnvironment variables
ViteVITE_*Static replacement of import.meta.env.VITE_* expressionsEnv variables and modes
Create React AppREACT_APP_*Substituted during npm run buildCustom environment variables
ExpoEXPO_PUBLIC_*Inlined into the app bundle at build timeEnvironment variables
AstroPUBLIC_*Static replacement in client-side codeEnvironment variables
NuxtNUXT_PUBLIC_*, runtime config publicSerialized into the rendered page payload at runtimeRuntime config

Two delivery styles exist, and both are fully public. Compile-time substitution writes the value into the JavaScript file itself. Runtime payload injection keeps the value out of static files but serializes it into the HTML or a payload script served with the page — visible to anyone who views source. The distinction changes caching behavior, not secrecy.

The prefix is a promise about intent, enforced by nothing. It does not inspect the value. It does not distinguish a tracking ID from a live payment credential. When a secret-bearing name gets the prefix — whether by misunderstanding, haste, or an assistant following the path of least resistance — the framework does exactly what it was told.

What substitution looks like in real output

Frameworks perform expression-level replacement. Wherever the compiler sees the marked expression in code destined for the browser, it swaps in the string literal. Here is a module before the build:

// src/lib/analytics.js
export function track(event) {
  return fetch(`${import.meta.env.VITE_EVENTS_URL}/collect`, {
    method: "POST",
    // Synthetic placeholder in Stripe test-key format. Not a real key.
    headers: { Authorization: `Bearer ${import.meta.env.VITE_STRIPE_KEY}` },
    body: JSON.stringify({ event }),
  });
}

After bundling and minification, the deployed chunk contains the resolved literals — formatted here for readability, otherwise untouched:

function t(e){return fetch("https://events.example.internal/collect",{
method:"POST",headers:{Authorization:"Bearer sk_test_51SYNTHETICPLACEHOLDERdoNOTship"},
body:JSON.stringify({event:e})})}

The synthetic placeholder above is deliberately obvious; a real key is visually indistinguishable from any other base64-ish blob in minified output. Three properties of the mechanism are worth internalizing:

  1. Minification preserves strings. Minifiers rename variables, drop whitespace, and simplify expressions. String literals pass through intact because mangling them would break the program. A key is a string.
  2. The value freezes at build time. Editing the variable in your host's dashboard afterward does nothing to already-built chunks. The fix requires a rebuild and a redeploy — which also means a stale leak can persist behind a fixed-looking configuration.
  3. Nothing validates intent. The pipeline cannot tell VITE_MAPS_KEY from VITE_STRIPE_SECRET. The prefix converts a name into bundle content, unconditionally, whenever a value exists.

Source maps rehydrate your source

Bundles are hard to read by design, so builds can emit source maps — .map files that map minified positions back to original sources. The map file contains a sourcesContent array holding your pre-build source text. If maps ship publicly, a reader gets back something close to the code you wrote, including lines where values were substituted before minification ever applied.

Defaults vary by tool, which makes this easy to misjudge:

ToolProduction source-map default
Create React AppGenerated (GENERATE_SOURCEMAP=true unless disabled)
ViteNot generated unless build.sourcemap is enabled
Next.jsBrowser-facing maps off unless productionBrowserSourceMaps is enabled

The practical check costs one request: append .map to a deployed script URL and see whether you get JSON. Teams that need maps for error reporting should upload them privately to their error tracker rather than serving them; the public static directory is the wrong home for them.

The five-minute self-check

You do not need special tooling to audit one page-load's worth of JavaScript. Everything below runs in a POSIX shell; the same steps work in PowerShell with Select-String in place of grep.

Step 1 — capture the page and list its scripts:

SITE="https://your-site.example"
curl -sL "$SITE" -o page.html

grep -oE 'src="[^"]+\.js[^"]*"' page.html \
  | sed -E 's/src="//; s/"$//' | sort -u

Step 2 — download each script, resolving relative paths against the origin:

mkdir -p js && cd js
for p in $(grep -oE 'src="[^"]+\.js[^"]*"' ../page.html | sed -E 's/src="//; s/"$//'); do
  case "$p" in
    http*) u="$p" ;;
    /*)    u="$SITE$p" ;;
    *)     u="$SITE/$p" ;;
  esac
  curl -sL "$u" -o "$(basename "${p%%\?*}")"
done

Step 3 — search for credential formats. These patterns detect format families, not validity; they will happily match synthetic examples like the ones in this article:

grep -hoE 'sk_(live|test)_[A-Za-z0-9]{10,}' ./*.js        # Stripe secret/restricted
grep -hoE 'sb_secret_[A-Za-z0-9_]{10,}' ./*.js            # Supabase secret keys
grep -hoE 'AKIA[0-9A-Z]{16}' ./*.js                        # AWS access key IDs
grep -hoE 'xox[bp]-[0-9A-Za-z-]{10,}' ./*.js               # Slack tokens

Any hit on sk_live_, rk_live_, sb_secret_, AKIA…, or provider SDK keys outside the expected public set means rotation comes first — see below.

Scaling the sweep with HAR and manifests. For broader coverage without DevTools clicking, export a HAR while exercising the app, then grep every response body offline:

# HAR files are JSON; extract JS bodies and scan them:
python3 - <<'EOF'
import json, sys
har = json.load(open(sys.argv[1]))
for e in har["log"]["entries"]:
    url = e["request"]["url"]
    if ".js" in url:
        body = e["response"]["content"].get("text", "")
        for marker in ("sk_live_", "sk_test_", "sb_secret_", "AKIA", "ghp_", "xoxb-"):
            if marker in body:
                print(f"{marker}  {url}")
EOF

And when your stack emits a build manifest (Next.js app-path manifests, Vite's bundle graph), fetch or generate the full chunk list first — auditing from the manifest beats discovering routes you forgot existed.

In-browser equivalent: open DevTools → Sources (Firefox: Debugger) → use Search Across All Files for the same prefixes. This catches lazily loaded chunks that the initial HTML does not reference — the main blind spot of the curl approach, since modern apps load much of their code after navigation. A thorough sweep enumerates chunks from the network log while exercising the app, which is precisely the tedious, repeatable kind of work automation exists for.

Keys that belong there, keys that never do

A bundle audit produces false alarms unless it distinguishes credential classes. Some keys exist to be public:

Format prefixProviderClient-bundle verdict
pk_live_ / pk_test_Stripe publishableExpected — designed for front-end code (Stripe)
sb_publishable_… (and legacy anon JWTs)SupabaseExpected only with working Row Level Security (Supabase)
Web apiKey in Firebase config (AIza…)FirebaseExpected — identity, not authorization (Firebase FAQ)
sk_live_ / rk_live_Stripe secret/restrictedNever
sb_secret_… / legacy service_role JWTSupabaseNever — bypasses RLS entirely
AKIA… access key IDsAWS IAMNever
sk-proj-, sk-svcacct-, sk-ant-api03-OpenAI / AnthropicNever

The middle rows carry nuance worth reading twice: a Supabase publishable key in the bundle is correct architecture conditional on RLS actually enforcing policies, which is its own failure mode discussed in the Supabase blast-radius guide. Detection tuned to these distinctions lives in the scanner rules documentation; flagging legitimate public keys is how scanners train teams to ignore them.

Found one? Rotate before you redeploy

Order matters, and the intuitive order is wrong. The instinct is to fix the code, rebuild, deploy the clean bundle, then quietly rotate. Between fix-deploy and rotation sits an interval where the old bundle may still be cached, CDN-purged incompletely, or held in service workers — while you believe the problem is solved.

The defensible sequence:

  1. Rotate at the provider immediately. A shipped key is disclosed; treat it as compromised regardless of exposure duration. Provider-specific procedures: Stripe, Supabase, OpenAI and Anthropic, AWS.
  2. Remove the secret from build inputs — move the call server-side or behind a proxy route; renaming the variable without moving the usage just relocates the problem.
  3. Rebuild and deploy, then re-run the check above against the live URL and confirm zero hits.
  4. Review provider logs for the exposed window — request logs, audit logs — to learn whether the key was used by anyone other than your application.

Bundles drift on every deploy

The uncomfortable property of bundle hygiene is that it decays. Dependencies update and bring new client-included config patterns. Refactors move a server-only call into a component. An AI assistant regenerates a module using the prefix pattern it saw elsewhere in the repo. Each deploy is a fresh roll of the dice against everything you checked last time.

That is the argument for treating bundle inspection as infrastructure rather than a periodic manual chore: KeyDrift monitors deployed bundles, re-checking on deploys and on schedule, and distinguishes the keys that belong from those that don't. The free scan answers the point-in-time question; monitoring answers the ongoing one.

Lazy chunks, service workers, and the limits of page-source checks

The five-minute procedure audits what the initial HTML references. Modern applications load most JavaScript after navigation — route-level chunks fetched on demand, dynamically imported modules, editor surfaces existing only on specific pages. A leak inside such a chunk is invisible to every check anchored on the homepage's source. Three extensions close most of the gap manually:

  1. Walk the app while recording. DevTools → Network → filter JS → visit every route a real user would. The network log then enumerates the true chunk set; Save all as HAR with content exports bodies for grepping.
  2. Read the manifest when one exists. Next.js emits build manifests referencing every chunk; Vite prints the production bundle graph at build time. The manifest is the honest inventory — list its files, fetch each.
  3. Check cached layers deliberately. Service workers hold old bundles indefinitely: a key removed from current code persists in the installed worker's precache until the worker updates. Unregister workers during audits, or fetch the worker script itself and scan it too.

This is where manual checking meets its economic ceiling: thorough coverage means driving the application like a user, capturing hundreds of chunks, and repeating after every deploy. That repetition — not the detection logic — is what automation absorbs, which is the practical case for continuous monitoring even among teams who know exactly how to check by hand.

Service-worker precaches deserve one explicit command during audits, since stale workers serve stale bundles indefinitely:

curl -s https://your-site.example/sw.js | grep -E 'PRECACHE|\.js'

Any chunk names listed there join the fetch-and-grep set. If the worker version string has not changed across recent deploys, cached generations may persist client-side long after server-side fixes — one more reason rotation precedes redeployment in the response order above.

Third-party scripts and window globals

Your bundles are only half the JavaScript your page executes. Vendor SDKs — analytics, support widgets, payment elements — load their own scripts and routinely publish configuration onto global objects: window.__INITIAL_STATE__, window.STRIPE_PUBLISHABLE_KEY, intercomSettings, datadog-style init blobs. Two properties make them worth auditing alongside first-party chunks. They carry whatever values integration guides told developers to paste, which historically included keys that should never have left servers. And they bypass every source-side control entirely — installed by copy-paste, loaded at runtime, invisible to git.

The audit extends naturally: view-source the rendered DOM (not just linked scripts), search for window. assignments holding recognizable value shapes, and check whether any tag-manager container injects configuration containing credentials. Findings here respond identically — rotate first, then move the value behind your own server route — but prevention differs slightly: vendor integrations deserve the same prefix-tier review as first-party code, treating "paste this snippet" instructions as untrusted until the snippet's contents are classified (the tier table applies verbatim).

One habit makes all of this stick: attach the check to shipping rather than to memory. Teams that grep their bundle as part of the release ritual catch regressions the week they land; teams that check when they remember measure exposure in months. The five-minute procedure earns its keep precisely at deploy time — which is also the argument for wiring it into machinery that never forgets a ship date.

FAQ

Isn't this only a risk for SPAs? Any difference for server-rendered apps? Server rendering shrinks the client bundle but adds payload surfaces: props serialized into HTML, flight/RSC payloads, runtime-config scripts. Any value passed from server to client components crosses the same public wire. The check is identical — read what the URL serves.

My keys are in .env, ignored by git, injected by my host. Isn't that the recommended setup? It is — for server-side consumption. The setup says nothing about which modules consume the variables. The moment a client-bundled module reads a prefixed variable, the recommended setup delivers the secret to the browser exactly as designed.

We found an old test-mode key in our bundle. Rotate or ignore? Rotate. Test-mode keys guard realistic sandbox data, and their presence signals the workflow that will eventually ship a live one. Removal cost is minutes; the habit being reinforced is worth more than the credential.

Can obfuscation or encryption-at-rest in the bundle help? No. Code arriving in the browser must decrypt or de-obfuscate itself to run, and the reader has the same access to that logic. Client-side hiding trades real audibility for imaginary protection.


Your bundle is public whether you audit it or not. Run the free scan at keydrift.dev/scan — no signup required — and get the answer for your deployed site in minutes.

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.