Skip to content
Information DisclosureExposed Secrets

Exposed Secrets in JavaScript: Bundles and Source Maps Publish Your Keys

Anything compiled into a JavaScript bundle or carried in a source map is readable by every visitor. Here is how API keys and private keys end up there, how to tell a public identifier from a real secret, and why rotation, not deletion, closes the exposure.

Part 3 of 3From the Information Disclosure series

At a glance

CWE-200
Interpreter
Any browser or script that downloads the application's JavaScript bundles and source maps
Required condition
A credential is compiled into a served script, or a published source map carries an original file that contains one.
Potential impact
Direct use of the credential: cloud access, billable API calls, minted tokens, or repository and package publishing.
Primary defense
Rotate the exposed credential, keep secrets behind authenticated server endpoints, and stop serving source maps with original sources.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

Every byte of JavaScript a site serves is public: the browser downloads it to run it, and anyone can request the same URL. A source map published next to the bundle goes further, because its sourcesContent field holds the original modules, comments and dead code included. A credential in either file is disclosed to every visitor.

There is no moment of theft to detect: the key is compromised from the first time the file was served, and only revoking it closes the exposure.

How secrets end up in a bundle

Build-time environment substitution. Build tools copy selected environment variables into the bundle as literal strings: Vite exposes VITE_ variables, Next.js inlines NEXT_PUBLIC_ ones and Create React App uses REACT_APP_. Give a server key one of those prefixes, or pass it through webpack's DefinePlugin, and it ships to every browser:

import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  region: "eu-central-1",
  credentials: {
    accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,
    secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY,
  },
});

Hard-coded test credentials. A key pasted in for a quick experiment survives into the release.

Source maps. A key in a commented-out block, or in a branch the minifier dropped, is still in sourcesContent.

Not every key in client code is a secret. Stripe publishable keys (pk_live_), Firebase web configuration and Google Maps browser keys are designed to be public: the provider limits what they can do, and key restrictions or security rules limit who can use them. The trouble is server keys: cloud credentials, secret API keys, private keys and access tokens.

A concrete walk-through: the bundle, then the map

An authorized tester filters the browser's network panel to scripts and fetches the main bundle:

GET /assets/index-4f2a91c7.js HTTP/1.1
Host: shop.example

Searching for prefixes such as AKIA, sk_live_ and -----BEGIN finds the values, and the last line points at a map:

const n=new Ve({region:"eu-central-1",credentials:{accessKeyId:"AKIAIOSFODNN7EXAMPLE",secretAccessKey:"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}});
//# sourceMappingURL=index-4f2a91c7.js.map

The map, at /assets/index-4f2a91c7.js.map, answers with the original source:

{"version":3,"sources":["../../src/api/storage.ts"],
 "sourcesContent":["...\n    accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,\n..."]}

The bundle holds the value; the map names the file and variable it came from. These are AWS's documentation example keys. With a real one, an authorized assessment stops here: calling the provider with a found credential, even for an identity check, needs the owner's explicit permission. The owner can find the access key ID in the IAM console, which also shows when it was last used.

What an attacker gains

  • Cloud keys grant whatever the key's policy allows: reading buckets, starting instances, fetching other secrets.
  • Private keys sign tokens and impersonate services; see JWT attacks.
  • Service tokens act on your account: Stripe refunds, SendGrid mail from your domain, paid OpenAI calls.
  • GitHub, GitLab and npm tokens reach repositories and packages, turning a leak into a supply-chain problem.
  • JWTs are usually one session's token, often expired, but they reveal claim structure.

Searching public JavaScript for documented key formats is routine, so assume any shipped key has been found.

Reading a SelfSec finding

The Client-Side Secret Scanner is passive. During the crawl it reads the external scripts the rendered pages load, plus worker scripts and lazily loaded chunks, scanning the first 512 KB of each file that answers 200 and is at most 2 MB. Script URLs that contain a common library name such as react or jquery, the word vendor or a public CDN host are skipped, so a key compiled into vendor.js goes unseen. When a script ends with a sourceMappingURL comment, the map, up to 10 MB, is fetched and each sourcesContent entry scanned; inline data: maps are not decoded.

  • Patterns cover AWS access and secret keys, GCP service-account JSON, PEM private keys, GitHub, GitLab, Slack, Stripe live secret, Google, OpenAI, SendGrid and npm tokens, JWTs, and quoted values of 32 or more characters near an api_key, apiKey or x-api-key name.
  • Provider and private-key matches are high severity; JWTs and generic tokens are medium. All carry CWE-200 and OWASP A04:2025 Cryptographic Failures.
  • Evidence is the kind, script URL, line and a masked value such as AKIA********MPLE. A source-map hit carries the bundle's URL and the line in the original file, so one key can appear twice.
  • No credential is tried: a finding proves the string is published, not that it still works.
  • Every Google AIza key is reported high, since the format cannot tell a restricted browser key from a server key; check its restrictions before closing it.
  • Inline <script> blocks are not read by this module. In the page's HTML only the key-assignment check described under information disclosure sees them, so review inline configuration by hand.

The fix: rotate first, then move the call to the server

Revoke the exposed credential and issue a new one first; the old value lives on in caches, archives and downloaded copies. Review the provider's usage logs for the exposure window, then keep the replacement on the server. In Express, hand out a short-lived upload URL instead of AWS keys:

import { randomUUID } from "node:crypto";
import express from "express";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const app = express();
const s3 = new S3Client({ region: process.env.AWS_REGION });

app.post("/api/uploads", requireSession, async (req, res) => {
  const key = `uploads/${req.user.id}/${randomUUID()}.png`;
  const command = new PutObjectCommand({ Bucket: process.env.UPLOAD_BUCKET, Key: key, ContentType: "image/png" });
  const url = await getSignedUrl(s3, command, { expiresIn: 300 });
  res.json({ url, key });
});

requireSession stands for your existing authentication middleware. The browser PUTs the file to that URL with the same Content-Type; the server's credentials come from its role. In Next.js, keep the secret in a server-only module without the NEXT_PUBLIC_ prefix, so importing it from a client component fails the build:

import "server-only";
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

A Route Handler creates the payment with a server-computed amount and returns only the client_secret; the browser keeps the publishable key.

Then stop publishing original sources. Vite's build.sourcemap defaults to false; "hidden" writes maps without the reference comment, which you upload to your error tracker and delete from the deployed output. webpack's devtool: "hidden-source-map" does the same, and "nosources-source-map" omits sourcesContent. An nginx backstop:

location ~* \.map$ {
    return 404;
}

Fixes that do not hold

  • Obfuscating or Base64-encoding the key: the browser must decode it, and so can anyone.
  • Deleting it from the next build: old copies hold a working key until it is rotated.
  • Removing only the sourceMappingURL comment: the .map URL is still guessable.
  • Referrer restrictions on a server key: a client outside a browser sends any Referer.
  • A proxy that forwards anything: it moves the leak. Do one fixed operation, for an authenticated user, with rate limits.

A developer review checklist

  1. Search build output and every .map, not just source.
  2. Confirm every VITE_, NEXT_PUBLIC_ and REACT_APP_ variable is meant to be public.
  3. Classify each key in client code as a restricted public identifier or a secret that must move.
  4. Ship production source maps off, hidden and undeployed, or without sourcesContent.
  5. Keep a rotation runbook: who revokes each key and where its usage logs live.
  6. Run a secret scanner in CI and as a pre-commit hook.

References

SelfSec scanner

Find Information Disclosure in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list