Audit Your npm Package for Source Map Leaks Before Publishing
On this page
A source map is meant to help you debug. It maps positions in minified code back to your original files, and by default most bundlers also copy those files into it, in a field called sourcesContent. If the .map ends up in your npm tarball or on your web server, anyone who downloads it gets your original source tree back: comments, internal URLs, TODOs and unreleased features included. In March 2026 a widely used command-line tool shipped a source map in its npm package this way, and its original source became public through that one file. This guide is about the defensive side of that story. It shows how to check your own package and your own site before you publish.
Who this is for: maintainers of npm packages and front-end apps who want a repeatable check before each release. You need Node.js and npm. REA is optional and is used in Step 3 only.
What you will do:
- See exactly how a map carries source out of a build
- List what
npm packwould publish, and find.mapfiles - Open your own map in a local viewer to see what it reveals
- Audit the deployed site with REA’s
analyze-web-bundle - Fix the build and add a CI gate
Run these checks only against packages and sites you own or are authorised to test. See REA’s DISCLAIMER.md.
How a source map carries your source out
Two parts of a build matter.
- The comment. A bundler adds
//# sourceMappingURL=app.js.mapas the last line of a file. Browser DevTools, Node.js with--enable-source-maps, and error trackers follow it. A map that is linked this way is easy to find: open the bundle and read its last line. - The content. The map’s
sourcesarray lists original paths. ThesourcesContentarray holds the full text of each one. WithsourcesContent, a single.mapfile is enough to rebuild the tree. Without it, the map still reveals file names and positions, but not the code.
So there are two separate questions for every release: is the map shipped at all, and if it is, does it carry content?
To keep this concrete, the rest of the guide uses a tiny package called greet-kit, built with Vite 8 in library mode and build.sourcemap: true. It has two source files. One of them, src/internal/flags.ts, holds a staging URL and a release TODO that should never leave the repository:
// Internal only: staging switches for the next release.
// TODO(release): drop the beta flag before 2.0.
export const featureFlags = {
betaGreeting: false,
stagingApi: 'https://staging.internal.example/api',
};
Step 1: List what npm would publish
npm pack --dry-run builds the tarball file list without writing or uploading anything. It is the most direct answer to “what will people download?”:
npm run build
npm pack --dry-run
This is the real output for greet-kit before any fix (npm 10.9, trimmed to the file list):
npm notice Tarball Contents
npm notice 298B dist/greet-kit.js
npm notice 713B dist/greet-kit.js.map
npm notice 139B package.json
npm notice 192B src/index.ts
npm notice 216B src/internal/flags.ts
npm notice 151B vite.config.mjs
npm notice total files: 6
Two problems show up at once. The map is in dist/, and because package.json has no files field, npm also publishes src/ and the build config. For a script, use the JSON form:
npm pack --dry-run --json | jq -r '.[0].files[].path' | grep -E '\.map$'
For greet-kit this prints dist/greet-kit.js.map. An empty result means no map is in the tarball. It does not yet mean that your JavaScript is free of sourceMappingURL comments; Step 4 covers that.
Why dry-run and not a glance at dist/: npm applies files, .npmignore, .gitignore and its own defaults. The dry-run list is the result of all of them, and it is what you actually publish.
Step 2: See what the map reveals
Before you delete anything, look at what a reader of the map would see. It tells you how serious the exposure is, and it is a good habit for any build you inherit.
Open the JS bundle and source map restorer and drop in dist/greet-kit.js together with dist/greet-kit.js.map. Everything runs in a Web Worker in your browser, and nothing is uploaded. For greet-kit, the tool’s report says:
| Field | Value |
|---|---|
| Map | greet-kit.js.map, regular map, file: greet-kit.js |
| Sources | 2, both with sourcesContent |
| Names / segments | 3 names, 22 mapping segments |
Bundle sourceMappingURL |
external, greet-kit.js.map |
| Original files | src/internal/flags.ts (216 bytes), src/index.ts (192 bytes) |
The source tree shows both files, and a preview of src/internal/flags.ts shows the staging URL and the TODO comment word for word. Download all as .zip would give a reader your src/ folder. Comments survive even though the minified bundle had dropped them.
On a real app, also check these lines of the report:
- node_modules share: how many sources are third-party. A map that is mostly
node_modulesexposes less of your own code, but it still exposes all of it. - Bundler hints: names such as
webpack://or/@fs/show which build produced the map and sometimes your local directory layout. - Sources without content: if every source is listed here, the map carries names and positions only. That is the
nosourcescase described under Step 4.
Step 3: Audit the deployed site with REA
A package is one channel; a web deployment is another. For a site you own, REA, the open-source project by morluto, can capture the scripts a real page loads and try to fetch the maps they declare. You need REA 6.4.0 or newer (npm install --global rea-agents, see the setup guide) and a Chrome started with a loopback debugging port and a throwaway profile:
# Linux; on macOS use "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
google-chrome --remote-debugging-port=9222 \
--user-data-dir=/tmp/rea-audit-profile https://your-site.example/
rea list-browser-targets http://127.0.0.1:9222 --json
rea analyze-web-bundle http://127.0.0.1:9222 TARGET_ID --fetch-source-maps --json > bundle-audit.json
analyze-web-bundle parses the captured scripts without running them. With --fetch-source-maps, it requests each declared map URL itself, without the browser’s cookies, from the page’s own origin (or from the origins you pass with --allowed-origins). The result’s observations.source_maps section is the part to read:
- Each item has a
declared_urland astatus.includedorpartialmeans the map was downloadable and parsed.fetch_failed(for example an HTTP 404),invalidandpolicy_filtered(outside the approved origins) mean it was not retained. - An included item lists
original_sources. A source whoseartifactis not null came with its text, which is the leak case. - The top-level
statusisnot_requested,included,partialorunavailable, andrequestedandprocessedtell you whether every declared map was tried.
Read the result with care. A fetch_failed only means that this request, without cookies, did not get the map. It is not proof that nobody can. A script with no sourceMappingURL declares nothing to fetch, and 6.4.0 only fetches http(s) map URLs on approved origins, so inline data: maps are not fetched here. To check those, save the bundle and open it in the source map tool from Step 2, which decodes inline maps. The website API tracing guide covers the browser setup in more depth.
You can also scan the build directory before it is deployed. rea analyze-javascript-application /absolute/path/dist --json reads every local sourceMappingURL and map it finds and records each original source name with a content digest when sourcesContent is present. That run also reads the files only and never executes them. See Recover JavaScript source from a bundle for how to read that Evidence.
Step 4: Fix the build
Pick the fix that matches what you need the maps for.
You do not need maps in production. Turn them off for the release build:
// vite.config.js
export default { build: { sourcemap: false } };
// webpack.config.js (production)
module.exports = { devtool: false };
With esbuild, leave out --sourcemap; with Bun, use --sourcemap=none.
You want readable production stack traces. Build hidden maps: the .map files are written, but the bundle carries no comment pointing to them. Keep the maps as build artifacts or upload them to your error tracker, and do not deploy or publish them. In Vite that is build.sourcemap: 'hidden'; in webpack, devtool: 'hidden-source-map'; in esbuild, --sourcemap=external. When greet-kit is rebuilt with 'hidden', dist/ still has greet-kit.js.map, but the last line of greet-kit.js is now export { t as greet }; with no comment. Decode a minified stack trace with a source map shows how to use such maps afterwards.
You must ship maps but not the code. webpack’s nosources-source-map and esbuild’s --sources-content=false write maps without sourcesContent. File names and line numbers stay visible, so treat this as a reduction, not a fix.
For npm packages, whitelist what you publish. Add a files field so only the build output goes into the tarball, and exclude maps explicitly:
{
"files": ["dist", "!dist/**/*.map"]
}
For greet-kit, the dry run now lists two files: dist/greet-kit.js and package.json. The map and src/ are gone. But the bundle still ends with //# sourceMappingURL=greet-kit.js.map, a link to a file that is no longer there. It does no harm, but it shows that files alone did not change how the build was configured, so combine it with a hidden or disabled map.
Step 5: Add a CI gate
A one-time check helps for this release only. A small script makes it permanent. This one runs npm pack --dry-run --json, then fails if the tarball contains a .map or a JavaScript file with a sourceMappingURL comment:
// scripts/check-pack.mjs
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const [pack] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json'], { encoding: 'utf8' }));
const problems = [];
for (const { path } of pack.files) {
if (path.endsWith('.map')) problems.push(`${path}: source map in tarball`);
else if (/\.(c|m)?js$/.test(path) && /[#@] sourceMappingURL=/.test(readFileSync(path, 'utf8')))
problems.push(`${path}: has a sourceMappingURL comment`);
}
if (problems.length) {
console.error(problems.join('\n'));
process.exit(1);
}
console.log(`ok: ${pack.files.length} files, no source maps`);
Run it from the package root after the build, for example as a prepublishOnly script. On greet-kit with only the files fix, it prints dist/greet-kit.js: has a sourceMappingURL comment and exits with status 1. After switching to sourcemap: 'hidden' and rebuilding, it prints ok: 2 files, no source maps.
For a web app, add the same idea to the deploy job: after the build, fail if find dist -name '*.map' finds anything in the upload directory, or move the maps out of that directory before you deploy.
Troubleshooting
The dry run lists no .map, but DevTools still shows my original files. The map is inline. The bundle ends with sourceMappingURL=data:application/json;base64,.... Search the built JavaScript for sourceMappingURL=data: and switch the build away from 'inline'.
files is set, but a map still ships. A .npmignore or a nested package.json can change the result, and a map can sit outside dist/. Trust the dry-run list, not the config.
REA retains no maps although the scripts declare them. The maps probably live on a CDN origin that differs from the page, so they are filtered (policy_filtered, or excluded from the capture). Pass both origins with --allowed-origins if you control them.
A third-party script on my page declares a map. That is not your source. Audit only the scripts you build and deploy.
FAQ
Should you use source maps in production?
Use them, but do not serve them. Hidden maps give you decoded stack traces through your error tracker while the public bundle points to nothing. Public maps make sense mainly for open-source projects whose code is already public.
Is removing the comment enough?
No. The comment only tells tools where to look. If app.js.map sits next to app.js on your server, people can still guess the URL. Keep hidden maps out of the deployed directory.
Does minification protect my code?
It removes comments and shortens names in the bundle, but a map with sourcesContent brings both back in full. See Recover JavaScript source from a bundle for what can and cannot be recovered when no map exists.
Next steps
- Decode production errors with hidden maps: /en/decode-minified-stack-trace-source-map/
- Open a map or bundle locally: JS bundle and source map restorer
- Capture a live page’s scripts and maps with REA: /en/trace-website-api-calls/
- Electron apps often ship maps inside
app.asar: /en/unpack-asar-file/