Securing Hidden Source Maps from Public Access
Exposing .map files on a public CDN hands anyone a full reconstruction of your minified production bundle — original variable names, file paths, business logic, and all. This page covers the three-layer approach to keeping source maps truly private while still feeding them to your error-tracking pipeline: suppressing the sourceMappingURL comment at build time, automating a post-build upload-then-delete workflow, and storing maps in access-controlled private object storage. It fits naturally alongside Configuring Webpack for Production Source Maps and Vite Build Settings for Accurate Stack Traces as part of the broader Source Map Generation & Stack Trace Debugging discipline.
After working through this page you will be able to:
- Configure Webpack’s
hidden-source-mapand Vite’ssourcemap: 'hidden'so bundles ship with nosourceMappingURLcomment. - Write a CI/CD post-build script that uploads
.mapfiles to a private S3-compatible bucket and deletes them from the deployment artifact before any deployment step runs. - Lock down the private bucket with an IAM policy that allows only your observability service account to read map objects.
- Add server-level deny rules as defense-in-depth so even a misconfigured deployment cannot serve
.mapfiles publicly. - Verify end-to-end that maps reach your error tracker but return
403from the public CDN.
Problem Framing & Symptom Identification
A .map file maps each byte in a minified bundle back to the original source line, column, and identifier. Anyone who downloads it can recover human-readable code from a production bundle in seconds using browser DevTools or a CLI tool like source-map. The risk is not theoretical — minified variable names are the only obfuscation layer most SPAs have.
The symptom is deceptively quiet: nothing breaks when maps are public. Your application works fine. Symbolicated stack traces arrive in your error dashboard. The only signal is the HTTP log showing 200 OK responses for GET /assets/main.abc123.js.map from IP addresses that are not your observability provider’s crawlers.
Three root causes account for most unintended public exposure:
sourceMappingURLcomment present in the bundle. When Webpack’sdevtoolis'source-map'(not'hidden-source-map'), every browser tab that loads your app will automaticallyGETthe.mapURL embedded at the end of the.jsfile, making the map effectively public.- Maps deployed alongside bundles. Many default CI pipelines
sync dist/ → CDNwithout excluding*.map. The map sits at a predictable, guessable URL next to the bundle. - Maps stored in a public S3 bucket or public CDN path. Even if the bundle omits the
sourceMappingURLcomment, a map stored with a public ACL is accessible to anyone who guesses the filename.
There is a fourth, subtler leak that automated scanners exploit: the legacy SourceMap: (and older X-SourceMap:) HTTP response header. Some build setups and proxies attach this header to the .js response as an alternative to the in-bundle comment. DevTools and scrapers honour it identically to sourceMappingURL, so a bundle that passes the grep "sourceMappingURL" test can still advertise its map through a response header. Treat header suppression as part of the same checklist.
Filename predictability compounds the problem. With [name].[contenthash].js, the map URL is exactly the bundle URL plus .map. An attacker who has the public bundle already knows the candidate map URL — there is no secret to guess. This is why the only durable defense is making the byte unreachable, not obscure: the map must either never reach the public path, or be rejected at the edge with a 403. Filename entropy buys nothing.
Prerequisites & Environment Setup
You need:
- Node.js ≥ 18 and npm/yarn/pnpm to run the build and post-build scripts.
- Webpack ≥ 5 or Vite ≥ 4 (the
devtool/build.sourcemapoption shapes differ slightly). - AWS CLI v2 or the AWS SDK v3 for Node (
@aws-sdk/client-s3) — or the equivalent for your cloud (GCS@google-cloud/storage, Cloudflare R2 via S3-compatible endpoint). - An IAM role or service-account credential with
s3:PutObjecton the private map bucket ands3:GetObjectonly for the observability service account. - Optionally:
sentry-cli≥ 2 or@sentry/cliif you’re using Sentry as your error tracker.
Install the S3 client and CLI helper:
npm install --save-dev @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install --save-dev @sentry/cli # skip if not using Sentry
Set environment variables in CI (never commit credentials):
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
export MAP_BUCKET="my-private-sourcemaps" # private bucket, no public ACL
export SENTRY_AUTH_TOKEN="sntrys_..."
export SENTRY_ORG="my-org"
export SENTRY_PROJECT="my-project"
Step-by-Step Implementation
Step 1 — Configure the bundler to emit hidden maps
For Webpack, set devtool: 'hidden-source-map'. This generates .map files on disk but omits the //# sourceMappingURL= comment from the bundle output. Browsers never know the map exists.
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
devtool: 'hidden-source-map', // .map written to disk, no comment in bundle
output: {
filename: '[name].[contenthash:8].js', // content hash ties bundle to its map
path: path.resolve(__dirname, 'dist'),
},
};
For Vite, the equivalent option is build.sourcemap: 'hidden':
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
sourcemap: 'hidden', // .map files emitted without sourceMappingURL
rollupOptions: {
output: {
entryFileNames: '[name].[hash].js',
chunkFileNames: '[name].[hash].js',
},
},
},
});
Run the build and confirm the output bundle contains no sourceMappingURL reference:
npm run build
grep -r "sourceMappingURL" dist/ && echo "FAIL: comment found" || echo "OK: no comment"
Step 2 — Upload maps to a private bucket
Write a post-build Node.js script that walks dist/, uploads every .map file to S3 under a release-specific prefix, and records the object keys for the deletion step.
// scripts/upload-maps.mjs
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { readdir, readFile, rm } from 'node:fs/promises';
import { join, relative } from 'node:path';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.MAP_BUCKET;
const RELEASE = process.env.RELEASE_VERSION ?? process.env.GITHUB_SHA?.slice(0, 8);
const DIST = new URL('../dist', import.meta.url).pathname;
async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else yield full;
}
}
const uploaded = [];
for await (const file of walk(DIST)) {
if (!file.endsWith('.map')) continue;
const key = `${RELEASE}/${relative(DIST, file)}`; // e.g. "a1b2c3d4/main.a1b2c3d4.js.map"
const body = await readFile(file);
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: 'application/json',
ServerSideEncryption: 'AES256', // encrypt at rest
}));
console.log(`Uploaded s3://${BUCKET}/${key}`);
uploaded.push(file);
}
// Delete .map files from dist/ so they are never deployed to the CDN
for (const file of uploaded) {
await rm(file);
console.log(`Deleted from dist: ${file}`);
}
Add this script to the build pipeline in package.json:
{
"scripts": {
"build": "webpack --config webpack.config.js",
"postbuild": "node scripts/upload-maps.mjs"
}
}
postbuild runs automatically after build completes. By the time any deploy step runs, dist/ contains only .js and .css files — no maps.
If your storage is Cloudflare R2 or Google Cloud Storage rather than S3, the upload primitive changes but the upload-then-delete shape does not. R2 exposes an S3-compatible endpoint, so the same @aws-sdk/client-s3 code works once you point the client at the R2 endpoint and pass R2 credentials:
// R2 uses the S3 API — only the endpoint and credentials differ
const s3 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
For Google Cloud Storage, swap the client and keep the same walk-and-delete loop:
// scripts/upload-maps-gcs.mjs (excerpt)
import { Storage } from '@google-cloud/storage';
const bucket = new Storage().bucket(process.env.MAP_BUCKET);
// inside the walk loop, after collecting `file`:
await bucket.upload(file, {
destination: key,
metadata: { cacheControl: 'private, no-store' }, // never cache map objects
});
The invariant to preserve across all three backends: the bucket is private by default, the write happens before deployment, and the local .map file is removed once the upload is confirmed.
Step 3 — Lock the bucket with an IAM policy
The S3 bucket must block all public access. Use a resource-based bucket policy that allows only the observability role to read objects.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyPublicRead",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-private-sourcemaps/*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::123456789012:role/ObservabilityServiceRole"
}
}
},
{
"Sid": "AllowObservabilityRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/ObservabilityServiceRole"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-private-sourcemaps/*"
},
{
"Sid": "AllowCIWrite",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/CIPipelineRole"
},
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::my-private-sourcemaps/*"
}
]
}
Also enable S3 Block Public Access at the bucket level. Apply it via the AWS CLI:
aws s3api put-public-access-block \
--bucket my-private-sourcemaps \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=false,RestrictPublicBuckets=true"
Step 4 — Configure Sentry to retrieve maps from the private bucket
If you use Sentry, tell sentry-cli to upload the maps directly from disk before the deletion step in postbuild. Sentry stores its own copy and uses it for server-side symbolication — it never needs to fetch from your CDN.
# Run this BEFORE upload-maps.mjs deletes the .map files
npx @sentry/cli sourcemaps upload \
--org "$SENTRY_ORG" \
--project "$SENTRY_PROJECT" \
--release "$RELEASE_VERSION" \
dist/
Alternatively, if your observability backend fetches maps on demand (e.g. a self-hosted symbolication service), generate pre-signed URLs with a short TTL and pass them in the error event:
// server-side: generate a 60-second pre-signed URL for symbolication
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function getMapUrl(release, filename) {
const command = new GetObjectCommand({
Bucket: process.env.MAP_BUCKET,
Key: `${release}/${filename}`,
});
return getSignedUrl(s3, command, { expiresIn: 60 }); // 60-second TTL
}
Step 5 — Add server-level deny rules as defense-in-depth
Even though maps are deleted from dist/ before deployment, enforce an explicit deny at the CDN or reverse proxy layer. If a future engineer accidentally re-adds a build step that copies maps to the public path, the server rule blocks them.
For Nginx serving static assets:
# nginx.conf — deny all .map requests at the server layer
location ~* \.map$ {
deny all; # 403 for every client
add_header X-Robots-Tag "noindex" always;
}
For Cloudflare (WAF custom rule expression):
(http.request.uri.path matches "\.map$")
Action: Block — this returns a 403 before the request reaches your origin.
Wire all five steps together in a single CI job so ordering is explicit and the deletion can never race the deploy. The key constraint is sequence: upload to the error tracker and the private bucket first, delete the local maps second, deploy third.
# .github/workflows/deploy.yml (excerpt)
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC for the CI role; no long-lived keys in the repo
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Build (emits hidden maps)
run: npm run build
env:
VITE_RELEASE_VERSION: ${{ github.sha }}
- name: Upload maps to Sentry, then private bucket, then delete locally
run: npm run postbuild # runs sentry upload + upload-maps.mjs (which deletes)
env:
RELEASE_VERSION: ${{ github.sha }}
- name: Assert no maps remain in dist/
run: bash ci-check-maps.sh # fails the build if any .map survived
- name: Deploy bundle only
run: aws s3 sync dist/ s3://public-cdn-bucket/ --exclude "*.map"
The --exclude "*.map" on the deploy step is redundant after a correct postbuild, but it is cheap insurance against a deletion failure that slipped past the assertion.
Production Telemetry Integration
Once maps are stored privately, your error-tracking pipeline must know where to find them. The connection between a runtime error event and the correct map version relies on an exact release identifier — the same string used as the S3 key prefix in Step 2.
Attach the release version to every error event at instrumentation time:
// src/telemetry.js — Sentry SDK initialization
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: process.env.VITE_SENTRY_DSN,
release: import.meta.env.VITE_RELEASE_VERSION, // injected at build time
environment: import.meta.env.MODE,
});
Inject VITE_RELEASE_VERSION during the build from the CI environment:
# In CI, set the version from the git SHA before running the build
export VITE_RELEASE_VERSION="$(git rev-parse --short HEAD)"
npm run build
For a self-hosted symbolication service, read the release field from the incoming error payload, construct the S3 key (${release}/${filename}.map), and resolve it using the Local Symbolication with Mozilla source-map Library approach:
// symbolication-service.mjs
import { SourceMapConsumer } from 'source-map';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: process.env.AWS_REGION });
async function fetchMap(release, filename) {
const { Body } = await s3.send(new GetObjectCommand({
Bucket: process.env.MAP_BUCKET,
Key: `${release}/${filename}.map`, // exact key written by upload-maps.mjs
}));
return Body.transformToString();
}
export async function symbolicate(release, filename, line, col) {
const rawMap = await fetchMap(release, filename);
return SourceMapConsumer.with(rawMap, null, (consumer) =>
consumer.originalPositionFor({ line, column: col })
);
}
Verification & Testing
After a build and deploy cycle, run the following checks from outside your internal network:
# Should return 403 or 404 — maps must NOT be reachable publicly
curl -I "https://cdn.example.com/assets/main.abc123.js.map"
# Expected: HTTP/2 403
# The bundle itself must load (sanity check)
curl -I "https://cdn.example.com/assets/main.abc123.js"
# Expected: HTTP/2 200
# Confirm no sourceMappingURL in the bundle
curl -s "https://cdn.example.com/assets/main.abc123.js" | grep "sourceMappingURL"
# Expected: (empty — no output)
# Confirm map exists in the private bucket (from an authorized CI context)
aws s3 ls "s3://${MAP_BUCKET}/${RELEASE_VERSION}/"
# Expected: list of .map files
Write a CI assertion step that fails the pipeline if any .map file survives in dist/ after postbuild:
# ci-check-maps.sh
MAP_COUNT=$(find dist/ -name "*.map" | wc -l)
if [ "$MAP_COUNT" -gt 0 ]; then
echo "ERROR: $MAP_COUNT .map file(s) found in dist/ — upload-maps.mjs did not clean up"
exit 1
fi
echo "OK: no .map files in dist/"
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
Maps return 200 despite hidden-source-map |
A second webpack-merge config re-sets devtool: 'source-map' for a code-splitting chunk |
Audit all webpack-merge overrides; run grep -r "devtool" webpack*.js in the repo |
postbuild runs but maps still appear in CDN sync |
CDN sync script uses aws s3 sync dist/ without --exclude "*.map" and is invoked in parallel before deletion finishes |
Ensure postbuild completes (upload + delete) before any sync step; add --exclude "*.map" to sync as a second guard |
| Symbolication fails after deploy (unresolved frames) | Release version mismatch between the S3 key prefix and the release field in the error event |
Ensure both build steps read the same RELEASE_VERSION env var from the same CI step |
| Map bucket accidentally set to public-read | Bucket ACL was re-set by a Terraform aws_s3_bucket_acl resource defaulting to public-read |
Add force_destroy = false and explicit aws_s3_bucket_public_access_block resource; enable AWS Config rule s3-bucket-public-read-prohibited |
| S3 upload fails mid-pipeline; partial maps in bucket | Network timeout or IAM permission error during PutObjectCommand |
Wrap each upload in a retry with exponential backoff; fail the pipeline on any upload error so a partial release is never deployed |
| Maps cached on CDN edge before server deny rule was applied | CDN served the map with a long Cache-Control: max-age before the deny rule was deployed |
Invalidate the CDN cache for *.map paths immediately after adding the deny rule; set Cache-Control: no-store on error responses |
FAQ
How do I verify that source maps are inaccessible to the public?
Run curl -I https://cdn.example.com/assets/main.abc123.js.map from a machine outside your VPN or internal network. A 403 or 404 confirms the map is protected. Cross-check from an allowlisted IP (e.g., the CI runner) to confirm the private bucket is still reachable for symbolication.
Can I still use browser DevTools for local debugging if production uses hidden-source-map?
Yes. Use devtool: 'hidden-source-map' only in the production Webpack config. Keep devtool: 'eval-source-map' (or 'source-map') in the development config, which is what webpack serve and vite dev use. The two configs are separate; the hidden setting never affects local development.
What if my error tracker fetches maps via HTTP rather than from S3 directly? Use the Serving Source Maps Behind Authentication pattern: expose a private endpoint that returns pre-signed S3 URLs valid for 60 seconds, and configure the error tracker to pass a Bearer token when requesting it. Never make the endpoint public.
Does nosources-source-map provide the same protection?
No. nosources-source-map (devtool: 'nosources-source-map') omits the source content from the map but keeps the sourceMappingURL comment. Browsers still fetch the map file, which exposes filenames and line numbers. Use hidden-source-map to suppress the fetch entirely; use nosources-source-map only when you intentionally want CDN-hosted maps that reveal structure but not content.
Related
- Source Map Generation & Stack Trace Debugging — parent reference covering the full build-to-symbolication pipeline
- Configuring Webpack for Production Source Maps —
devtooloptions, chunk naming, and deterministic hashes - Vite Build Settings for Accurate Stack Traces —
build.sourcemapmodes and Rollup output configuration - Serving Source Maps Behind Authentication — gating
.mapendpoints with Bearer tokens and pre-signed URLs - Restricting Source Map Access by IP Allowlist — nginx geo blocks and Cloudflare WAF rules for
.mappaths