Source maps in production: why your minified app isn't actually minified
You ship bundle.abc123.min.js to production — minified, transpiled, mangled
variable names. You think the source is hidden. Then someone opens your site in Chrome
DevTools, hits the Sources panel, and your unminified code with original variable names
and inline comments is right there, fully readable.
That's a source map. Webpack puts //# sourceMappingURL=bundle.abc123.min.js.map
at the bottom of your bundle. The browser fetches the .map file (which IS
your unminified source) and reconstructs the original tree. Default behaviour for nearly
every modern bundler.
Why this matters
Source maps are great in development (better stack traces, better debugging) and during error monitoring (Sentry, Datadog can de-minify stack traces). They are terrible in production-served:
- Business logic exposed — proprietary algorithms, pricing rules, fraud heuristics.
- API surface enumeration — every endpoint your frontend calls is in the source. Attackers don't have to fuzz; they read.
- Hardcoded secrets — env vars accidentally bundled, JWT signing keys (yes, we've seen this), Stripe publishable keys (less catastrophic but still leaks topology).
- Comments —
// TODO: figure out how to fix the auth bypass before launchin plain text.
How to detect
From your terminal:
$ curl -s https://example.com/ | grep -oE 'src=[^ ]*\.js' | head -3
src="/_app/immutable/start.7jP7wo7n.js"
$ curl -s https://example.com/_app/immutable/start.7jP7wo7n.js | tail -1
//# sourceMappingURL=start.7jP7wo7n.js.map
$ curl -sI https://example.com/_app/immutable/start.7jP7wo7n.js.map
HTTP/2 200 <- problem
content-type: application/json
content-length: 1547892
A 200 on the .map URL = your source is exposed. Most production sites we scan have at
least one. UnveilScan's source_maps_exposed checker (Extended profile)
automates this for up to 5 same-origin scripts.
How to stop
Webpack
// webpack.config.js
module.exports = {
mode: 'production',
devtool: false, // never emit source maps for prod build
// ...
};
If you need maps for monitoring (Sentry), build with devtool: 'hidden-source-map' — emits the .map file but doesn't include the sourceMappingURL reference at the bottom of bundles. You upload the .map to Sentry separately, then delete it from the prod artifact.
Vite / Rollup
// vite.config.js
export default {
build: {
sourcemap: false,
// OR for hidden Sentry-style:
// sourcemap: 'hidden'
}
};
Next.js
// next.config.js
module.exports = {
productionBrowserSourceMaps: false // default
};
Next.js default is false. If you set it to true for Sentry,
use the @sentry/nextjs SDK which uploads + strips automatically.
SvelteKit
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter()
// sourcemap controlled by Vite — see Vite section above
}
};
Server-side fallback
If you can't easily change the build, block at the server level:
# nginx
location ~ \.map$ { return 404; }
# Apache
<FilesMatch "\.map$">
Require all denied
</FilesMatch>
The Sentry use case
Sentry needs source maps to de-minify stack traces. The right pattern:
- Build with
devtool: 'hidden-source-map'(or equivalent). - Use the Sentry CLI / plugin to upload the
.mapfiles to Sentry's servers during deploy. - Delete the
.mapfiles from your production artifact before serving.
Sentry's CLI handles steps 2 and 3 in one command. Document it in your CI pipeline.
How UnveilScan checks this
Our source_maps_exposed checker parses HTML for <script src>,
picks up to 5 same-origin .js files, requests .map for each. We require
the response to start with {"version":3 or contain "mappings"
to avoid false positives from catch-all SPA fallbacks. Confirmed leaks emit a LOW
finding with the URL of each exposed map.
Find your exposed source maps
Extended scan probes up to 5 .js files for accompanying .map exposure.
See pricing