Generating Hidden Source Maps for Production Debugging

Shipping a source map next to your production bundle makes stack traces readable — and hands anyone your original source. Hidden source maps solve both: the build emits the map but omits the //# sourceMappingURL comment, so browsers do not fetch it publicly while you still hold the file to symbolicate errors privately. This guide configures that in Vite. It applies the source maps and production debugging group’s techniques to a security-conscious deploy; the parent overview is Core Concepts of Modern Bundling.

The tension this solves is structural, not incidental. A production bundle is minified, tree-shaken, and variable-mangled: a runtime error surfaces as TypeError: e is not a function at t.n (index-4f2a1c.js:1:88214), which tells you nothing about which component, which import, or which line of your actual code failed. A source map is the only artifact that reverses that transform — it carries the VLQ-encoded mapping from every generated position back to the original file, line, column, and symbol name. Without a map, a production stack trace is close to useless; with a public map, anyone who opens DevTools or requests the .map URL can reconstruct your entire pre-build source tree, including comments, internal file names, and any logic you assumed minification obscured. Most teams discover this the first time a competitor or a security researcher pastes their “minified” business logic back at them verbatim.

The naive responses both fail. Disabling source maps entirely (build.sourcemap: false) protects the source but leaves you debugging minified column offsets by hand, which does not scale past the first incident. Shipping a full public map (build.sourcemap: true) makes debugging trivial but is equivalent to publishing your unminified repository. Hidden maps are the third option and the correct default for any closed-source production application: the map is generated at full fidelity, but the reference that would make it public is stripped, so the file exists only where you deliberately place it — an error tracker’s private store, a CI artifact, or your own machine. This sits at the very end of the build pipeline, after minification and hashing, because the map has to describe the final emitted bytes; it is the last transform Rollup applies before Vite writes files to dist/.

Hidden maps are emitted but not linked from the bundle A hidden source map is generated by the build and uploaded to an error tracker or kept privately, but the sourceMappingURL comment is omitted from the deployed JavaScript so browsers never fetch the map publicly. Keep the map, drop the public link buildemit .js + .js.map deploy: .js onlyno sourceMappingURL upload: .js.mapto error tracker readable tracesprivately public gets minified code; you keep the map to symbolicate
Figure: the map exists and is used privately, but nothing in the public bundle points to it.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+
npm create vite@latest hidden-maps-demo -- --template react-ts
cd hidden-maps-demo && npm install

By default build.sourcemap: true emits the map and the sourceMappingURL comment, so the browser fetches it and DevTools shows your source publicly. The 'hidden' value is what separates the two.

Vite’s build.sourcemap accepts four values, and only one of them is what we want here. false (the default) emits no map at all. true emits the map and appends the //# sourceMappingURL=index-4f2a1c.js.map comment to the end of the bundle. 'inline' is the worst case for production: it base64-encodes the entire map and embeds it directly in the JS as a data URI, which both bloats the served bundle and exposes source unconditionally. 'hidden' emits the exact same .map file that true would, byte for byte, and simply omits the trailing comment. That single omission is the whole mechanism — there is no separate “private map” format, only the presence or absence of the pointer that tells a browser to go fetch it.

true versus hidden differ only by the URL comment The sourcemap true setting emits the map and a sourceMappingURL comment that browsers follow, while the hidden setting emits the identical map but omits that comment, which is the single difference that keeps source private. sourcemap: truemap + URL comment sourcemap: 'hidden'map, no comment one setting value is the entire difference
Figure: the only difference between the two settings is whether the bundle links to the map.

How it works under the hood

A source map is a JSON document with a fixed shape: version (always 3), a sources array of original file paths, an optional sourcesContent array holding the full text of each of those files, a names array of original identifiers, and a mappings string. The mappings string is the dense part — a series of semicolon-delimited groups (one per generated line) of comma-delimited segments, each segment a Base64 VLQ encoding of five deltas: generated column, source index, original line, original column, and name index. A debugger walks the generated position of a stack frame, binary-searches the decoded segments for the nearest preceding mapping, and reports the original file, line, and symbol it points at. Nothing about that resolution requires the map to be public; it only requires whoever is symbolicating to physically hold the file.

The sourcesContent field is why a public map is so much worse than it first appears. It does not merely map positions — for a Vite build it embeds the complete original source of every module inline, so the map is a self-contained copy of your codebase. That is exactly what makes DevTools able to show original source with no server round-trip, and exactly what you are protecting when you hide it. You can strip sourcesContent to shrink the map, but then the tracker cannot render source context around a frame, so for hidden-map workflows you keep it — the whole point is that the private consumer gets full fidelity.

The only difference 'hidden' makes to the emitted output is the last line of the bundle. With true, Rollup appends //# sourceMappingURL=<name>.js.map; with 'hidden', it does not. The map filename is still the bundle’s hashed name plus .map, and the map still contains a file field naming the bundle it describes, so an error tracker can pair them by name and hash without any comment in the JS. The comment is purely a browser convenience: it is the signal a browser uses to auto-fetch and auto-apply the map when DevTools is open. Remove it and the browser has no idea the map exists, while every tool you hand the file to directly works unchanged.

Diagnosis workflow

  1. Confirm the map is currently public. After a normal build, curl -sI <site>/assets/index-*.js.map returns 200 and the JS ends with a sourceMappingURL comment. This is the failure state you are correcting: two independent leaks, the served .map file itself and the comment that advertises it. Run the curl against a real deploy rather than trusting the config — a CDN or a static host can serve .map files even when your intent was to withhold them, and the 200 is the unambiguous proof that source is currently reachable by anyone.
  2. Decide where the map lives. Uploaded to an error tracker (Sentry, Bugsnag) at deploy time, or stored in a private artifact you load into DevTools manually. The choice drives the rest of the pipeline: a tracker needs the map keyed to a release identifier so it can match incoming stack traces to the right build, whereas a manual DevTools workflow needs the map filename preserved so you can point the debugger at it locally. Pick one before wiring CI, because the deletion step that guarantees privacy must run after whichever consumer has taken its copy.
  3. Ensure the deploy excludes public maps. The .map files must not be served from the web root even though they are generated. This is a property of the deploy artifact, not the build config: 'hidden' stops the link but still writes the file into dist/, so if your deploy uploads dist/ wholesale the maps ship anyway. Confirm the exclusion at the boundary where files enter the web root — a --exclude, a .gitignore-style ignore in your host config, or an explicit delete — never assume the build setting alone kept them back.
Public map versus hidden map A default build leaves the map fetchable at a public URL and links it from the JS, whereas a hidden build emits the same map but omits the link and the deploy step keeps the map file out of the web root. default: public map200 + sourceMappingURLsource exposed hidden: private mapno link, not serveduploaded to tracker same map file — different exposure
Figure: the difference is not the map but whether the public bundle links to it and serves it.

Annotated solution config

The config change is a single line, but the safety of the whole scheme lives in the deploy script that surrounds it. The Vite side declares intent — emit the map, do not link it — and the shell side enforces the invariant that the file reaches its private consumer and nothing else. Treat the two as a unit; a correct vite.config.ts with a careless deploy still leaks, and a careful deploy cannot recover a map the build never emitted.

// vite.config.ts — Vite 5.4.x, hidden source maps.
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // 'hidden' emits the .map file but omits the sourceMappingURL comment,
    // so browsers do not fetch it and DevTools does not auto-load source.
    sourcemap: 'hidden',
  },
});
# Deploy step — publish the JS, hold back the maps, then upload them privately.
npx vite build
# Copy only the JS/CSS/HTML to the web root; keep *.map out.
rsync -a --exclude='*.map' dist/ ./public-deploy/
# Upload the maps to your error tracker with the matching release.
npx sentry-cli sourcemaps upload --release "$GIT_SHA" dist/assets
# Then delete the local maps so they never leak from the artifact.
find dist -name '*.map' -delete

The ordering is load-bearing and the failure modes are asymmetric. Upload must precede delete, because the tracker needs to read the file before it is destroyed; reverse the two and you have deleted the only copy of the map for a build you can no longer reproduce byte-for-byte, since a later rebuild will hash differently and no longer match the deployed JS. The rsync --exclude='*.map' and the trailing find … -delete are belt and braces on purpose: the exclude keeps maps out of the copy that becomes the web root, and the delete removes them from the build directory itself so a second, unguarded step later in the pipeline cannot pick them up. If your host deploys straight from dist/ rather than from a separate public-deploy/, drop the rsync and rely on the find, but never rely on the sourcemap: 'hidden' setting alone to keep files out of a directory it deliberately wrote them into.

If you cannot use sentry-cli — a self-hosted tracker, an internal symbolication service, or a manual DevTools workflow — replace the upload line with a copy into a private, authenticated store keyed to the commit:

# Alternative to a tracker CLI — archive maps privately, keyed to the build. Uses AWS CLI v2.
# Store maps in a bucket that is NOT the web root and has no public read policy.
aws s3 cp dist/assets/ "s3://build-artifacts-private/$GIT_SHA/" \
  --recursive --exclude '*' --include '*.map'
# Public deploy still ships JS only; the private bucket holds the maps by commit.

To symbolicate later from that archive, download the matching commit’s .map next to the deployed bundle and use DevTools’ “Add source map” on the minified file, or feed both to a stack-trace resolver such as source-map’s SourceMapConsumer. The key discipline is the same as with a tracker: the map is retrieved by the exact commit that produced the JS, never by “latest”.

Emit, upload, then exclude from the public artifact The build emits hidden maps, the deploy uploads them to the error tracker keyed to the release, and then excludes or deletes the map files so the public artifact contains only the minified JavaScript. emit hidden mapsno link upload to trackerkeyed to release delete from artifactnever public the delete step is what guarantees the map never ships
Figure: uploading before deleting is the order that gives the tracker the map and the public nothing.

Verification

# Vite 5.4.x — confirm the JS has no map link and the map is not served.
npx vite build
tail -c 100 dist/assets/index-*.js | grep -c sourceMappingURL   # expect 0
# After deploy, the public map URL should 404:
curl -sI https://your-site/assets/index-*.js.map | head -1        # expect 404

A correct setup emits a .map file locally, but the deployed JS has no sourceMappingURL comment and the public map URL returns 404 — while the error tracker still resolves minified stack traces to your source because it holds the uploaded map. These three checks are independent and all three must pass: the grep count proves the build stripped the link, the curl proves the deploy withheld the file, and a symbolicated trace in the tracker proves the private copy actually arrived. Checking only the first is the common mistake — a stripped comment with the .map still sitting in the web root is a silent, total leak, because anyone can guess the .js.map name from the bundle name and fetch it directly.

Make the negative checks fail the build rather than merely printing a number, so a regression cannot ship quietly:

# Vite 5.4.x — hard gate: nonzero exit if any deployed JS links a map.
if grep -rl 'sourceMappingURL' public-deploy/assets --include='*.js' | grep -q .; then
  echo 'FAIL: a deployed bundle links a source map' >&2
  exit 1
fi
# Hard gate: nonzero exit if any map file survived into the deploy artifact.
if find public-deploy -name '*.map' | grep -q .; then
  echo 'FAIL: a .map file is present in the deploy artifact' >&2
  exit 1
fi
No public link, 404 map, readable traces privately Verification is that the deployed JS contains no sourceMappingURL, the public map URL returns 404, and yet the error tracker still symbolicates stack traces because it holds the uploaded map. JS: no linkgrep = 0 public map: 404not served tracker: readablesymbolicated three conditions together confirm the hidden-map setup
Figure: all three must hold — no link, a 404 map, and still-readable traces in the tracker.

Gotchas & edge cases

Four hidden-source-map edge cases Maps left in the deployed artifact leak source even without a link; a release mismatch between the uploaded map and the deployed JS breaks symbolication; forgetting to delete local maps in CI can publish them; and CSS source maps need the same hidden treatment. maps left in artifact→ source leaks anyway release mismatch→ key map to the JS hash forgot to delete in CI→ publishes maps CSS maps too→ hide them as well
Figure: a map left in the artifact leaks source even with no link — the delete step is not optional.
  • Maps in the artifact still leak. Removing the link but shipping the .map in the deploy folder still exposes source. The symptom is subtle because nothing in the served HTML or JS references the map, so a casual check of the page looks clean; the root cause is that the file is still physically served at a predictable URL — swap .js for .js.map in any bundle name and request it. The fix is to exclude or delete the files at the deploy boundary, and you confirm it with the curl … | head -1 check returning 404 against the live host, not against your local dist/.
  • Release mismatch. The uploaded map must match the exact deployed JS; key both to the same release/commit or symbolication fails. The symptom is a tracker that shows minified frames despite a map being uploaded, or worse, maps a frame to the wrong line and sends you chasing a bug in unrelated code. The root cause is that a source map is valid only for the precise bytes it was generated from: a rebuild between upload and deploy changes the content hash and invalidates every mapping. Fix it by generating once and deriving both the deployed JS and the uploaded map from that single build, tagged with the same immutable $GIT_SHA, and confirm by comparing the file field inside the map against the deployed bundle’s filename.
  • CI deletion. Automate the map deletion in the pipeline; a manual step gets skipped and publishes maps. The symptom shows up weeks later as an unexpected 200 on a .map URL that a routine scan catches. The root cause is that any human step in a repeated deploy is eventually forgotten, especially under a hotfix rush. The fix is the hard-gate check above running as a required CI stage so a surviving map file fails the pipeline before it can reach the web root; a passing gate is the confirmation.
  • CSS maps. build.css.sourcemap maps leak stylesheets the same way; apply the same hidden treatment. The symptom is a .css.map returning 200 even after you have locked down the JS maps, because CSS source maps are governed by a separate flag and are easy to overlook. The root cause is that Vite emits stylesheet maps through its own option, independent of build.sourcemap. The fix is to bring CSS maps under the same exclude/delete discipline — the --exclude='*.map' and find … -delete already cover both extensions — and to confirm by extending the 404 check to the .css.map URLs.

CI integration

The whole scheme collapses into one requirement in CI: build exactly once, then fan that single artifact out to two destinations with different contents. Concretely, run vite build a single time, upload the maps from dist/ to the tracker or private store while they still exist, run the two hard-gate checks against the copy destined for the web root, and only then publish that copy. Do not run separate builds for “the deploy” and “the source maps” — two builds produce two different content hashes and the map you uploaded will not describe the JS you shipped. On most CI providers this means the build step and the deploy step share a workspace or pass dist/ as a build artifact between jobs, rather than each job checking out and rebuilding.

Guard the release identifier as carefully as the files. $GIT_SHA must be the commit that produced the artifact, resolved once and threaded through both the upload and any release-tagging call, so the tracker’s release, the deployed bundle, and the stored map all agree. If your provider exposes only a short SHA in one place and a long SHA in another, normalize to one form; a tracker that keys releases on a mismatched string will accept the upload and then fail to match live events to it, which reproduces the release-mismatch failure with no obvious error.

Performance considerations

Hidden maps have zero runtime cost, and that is a genuine advantage over 'inline'. Because the sourceMappingURL comment is absent and no .map is served, the bytes shipped to the browser are identical to a build with sourcemap: false; there is no extra request, no larger bundle, and no parsing overhead, since browsers only fetch the map when DevTools is open and a link is present. The cost is entirely at build time and in storage. Generating full maps with sourcesContent makes the build write a file that is often larger than the bundle it describes, so build I/O and disk usage rise, and on very large applications map generation is a measurable slice of total build time. This is worth it: the alternative is undebuggable production. If build time becomes a real constraint, the lever is not disabling maps but scoping them — generate maps only for the production deploy, not for preview or ephemeral branch builds that no tracker will ever symbolicate against.

When not to use this

Hidden maps are the wrong choice in two cases. If your source is already open — a public repository, a library published to npm with sources — hiding the map buys nothing and costs you the convenience of in-browser debugging; ship sourcemap: true and let anyone symbolicate. And if you have no private consumer at all — no error tracker, no archive, no intent to ever load the map — then 'hidden' produces a file that helps no one and merely risks leaking if a deploy step misbehaves; either wire up a consumer first or set sourcemap: false and accept minified traces. Hidden maps earn their keep precisely when the code is closed and something private will actually read the map; absent either condition, pick one of the endpoints instead.

Comparison with public and inline maps

Against sourcemap: true, the difference is exposure, not capability: both emit identical maps, and both let a holder of the map get perfect stack traces. true simply publishes the map to everyone by linking and serving it, which is correct for open source and unacceptable for closed source. Against 'inline', the difference is both exposure and payload: inline maps embed the base64 map in the bundle, so they always leak and inflate every byte the browser downloads, making them a development-only mode that should never reach production. Against false, the difference is debuggability: false is the only truly leak-proof option because no map exists, but it leaves you reading raw minified offsets, which is why 'hidden' is the pragmatic default — it gives false’s public surface with true’s debugging power, at the price of a disciplined deploy that keeps the file private.