Serving Source Maps Behind Authentication

When IP allowlisting alone is insufficient — for example, when your error tracker fetches maps from a cloud SaaS endpoint rather than a fixed CIDR range — you need an authentication layer in front of the .map endpoint. This page covers three concrete patterns for gating source map delivery with Bearer tokens and pre-signed URLs, so symbolication works for your error tracker while every unauthenticated request gets a 401. It sits alongside Securing Hidden Source Maps from Public Access as part of the Source Map Generation & Stack Trace Debugging reference.

Auth-Gated Source Map Delivery Flow Sequence flow: error tracker sends GET /assets/main.js.map with Authorization: Bearer token to nginx; nginx issues auth_request to token-validation service; on 200, nginx proxies to private S3; on 401, nginx returns 401 to caller. Unauthenticated requests are rejected before reaching S3. Error Tracker Bearer token GET *.map nginx auth_request Token Validator 200 or 401 auth OK Private S3 Bucket returns .map Public Request 401 Unauthorized no token

Symptom / Trigger

The map endpoint responds 200 OK for any caller, authenticated or not:

# From any public IP — should return 401, actually returns 200
curl -I https://cdn.example.com/assets/main.a1b2c3d4.js.map
# HTTP/2 200
# content-type: application/json
# content-length: 1842391

Browser DevTools also auto-loads the map because the bundle still contains a //# sourceMappingURL= comment (or the CDN is serving maps at their predictable URL):

# In Chrome DevTools → Sources → Page
# You can see original source files: src/auth/login.ts, src/billing/checkout.ts …

This means anyone who opens DevTools on your production site can browse reconstructed source — not just your error tracker.

Root Cause Explanation

The .map endpoint has no authentication middleware. The nginx or CDN configuration delivers map files to any HTTP client that knows (or guesses) the URL:

# BROKEN: static file serving with no auth, .map files included
server {
  root /var/www/dist;
  location / {
    try_files $uri $uri/ =404;   # serves *.map to everyone
  }
}

Even if the bundle uses hidden-source-map (suppressing the sourceMappingURL comment), the map file sits at a predictable path: the bundle filename with .map appended. Any automated scanner enumerating *.js.map URLs will find it.

Step-by-Step Fix

1. Generate pre-signed URLs for on-demand map retrieval

Store maps in a private S3 bucket (or Cloudflare R2 with S3-compatible API) and issue pre-signed URLs that expire after 60 seconds. The symbolication backend calls this endpoint when it needs to resolve a stack frame, then uses the short-lived URL to fetch the map directly from the bucket.

// src/map-signing.mjs — generates pre-signed S3 URLs for private maps
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

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

/**
 * Returns a 60-second pre-signed URL for a .map file in the private bucket.
 * release: git SHA short (e.g. "a1b2c3d4")
 * filename: bundle name without extension (e.g. "main.a1b2c3d4.js")
 */
export async function signedMapUrl(release, filename) {
  const command = new GetObjectCommand({
    Bucket: process.env.MAP_BUCKET,                     // private bucket
    Key: `${release}/${filename}.map`,                  // matches upload-maps.mjs key structure
  });
  return getSignedUrl(s3, command, { expiresIn: 60 }); // URL valid for 60 seconds only
}

The symbolication service calls signedMapUrl and passes the URL to SourceMapConsumer — the map never transits through a public host.

For Cloudflare R2, use the same AWS SDK v3 with the R2 endpoint override:

const s3 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});

2. Configure the error tracker’s Authorization header

If your error tracker fetches maps via HTTP (e.g. a self-hosted Sentry that calls back to your private endpoint), configure it to pass a Bearer token with every map request.

Sentry’s sourceMaps integration accepts a custom headers option when you initialize the SDK with a private map URL:

// sentry.server.config.js — self-hosted Sentry fetching maps from your auth endpoint
import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.RELEASE_VERSION,
  integrations: [
    Sentry.rewriteFramesIntegration({
      root: '/',
    }),
  ],
  // For self-hosted: set the artifact URL pattern and auth token
  // so Sentry fetches *.map from your gated endpoint
});

When uploading maps via sentry-cli, specify a custom url-prefix pointing to your auth-protected endpoint and pass the token as an environment variable:

SENTRY_AUTH_TOKEN="$SENTRY_UPLOAD_TOKEN" \
npx @sentry/cli sourcemaps upload \
  --org "$SENTRY_ORG" \
  --project "$SENTRY_PROJECT" \
  --release "$RELEASE_VERSION" \
  --url-prefix "https://maps-internal.example.com/sourcemaps/$RELEASE_VERSION/" \
  dist/

For a custom error-tracking backend, attach the token in the request:

// Custom symbolication backend fetching a .map via auth endpoint
const mapResponse = await fetch(
  `https://maps-internal.example.com/sourcemaps/${release}/${filename}.map`,
  {
    headers: {
      Authorization: `Bearer ${process.env.MAP_FETCH_TOKEN}`, // long-lived service token
    },
  }
);
if (!mapResponse.ok) throw new Error(`Map fetch failed: ${mapResponse.status}`);
const rawMap = await mapResponse.text();

3. Add nginx auth_request validation

Use nginx’s auth_request directive to delegate authentication to a lightweight token-validation microservice. The microservice returns 200 for valid tokens and 401 for invalid or missing ones. nginx only proxies to the private S3 bucket on a 200.

# nginx.conf — auth_request guard for *.map paths
server {
  listen 443 ssl;
  server_name maps-internal.example.com;

  # Auth subrequest endpoint
  location = /_auth {
    internal;                                         # not directly accessible
    proxy_pass http://token-validator:3000/validate;  # internal microservice
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header Authorization $http_authorization; # forward the Bearer token
  }

  location ~* \.map$ {
    auth_request /_auth;                              # 401 from validator → nginx returns 401
    auth_request_set $auth_status $upstream_status;

    # On auth success, proxy to private S3 origin
    proxy_pass https://my-private-sourcemaps.s3.us-east-1.amazonaws.com;
    proxy_set_header Host my-private-sourcemaps.s3.us-east-1.amazonaws.com;
    proxy_set_header Authorization "";               # strip Bearer token before forwarding to S3

    # Cache the map for 5 minutes on this proxy (not on clients)
    proxy_cache_valid 200 5m;
    add_header Cache-Control "private, no-store";   # no client-side or CDN caching
  }

  # Return 401 with WWW-Authenticate when auth_request rejects
  error_page 401 = @unauthorized;
  location @unauthorized {
    add_header WWW-Authenticate 'Bearer realm="source-maps"' always;
    return 401;
  }
}

The token-validation microservice is a minimal Node.js HTTP server:

// token-validator/index.mjs
import { createServer } from 'node:http';

const VALID_TOKENS = new Set(process.env.MAP_TOKENS.split(',').map(t => t.trim()));

createServer((req, res) => {
  const auth = req.headers['authorization'] ?? '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
  if (VALID_TOKENS.has(token)) {
    res.writeHead(200).end();
  } else {
    res.writeHead(401).end();
  }
}).listen(3000, () => console.log('Token validator listening on :3000'));

In production, replace the in-memory set with a lookup against your secrets manager (AWS Secrets Manager, HashiCorp Vault) or a short-lived JWT verification.

4. Verify with curl — authenticated vs. unauthenticated

# Without token — must return 401
curl -I https://maps-internal.example.com/sourcemaps/a1b2c3d4/main.a1b2c3d4.js.map
# Expected: HTTP/2 401
# www-authenticate: Bearer realm="source-maps"

# With valid token — must return 200
curl -I \
  -H "Authorization: Bearer $(echo $MAP_FETCH_TOKEN)" \
  https://maps-internal.example.com/sourcemaps/a1b2c3d4/main.a1b2c3d4.js.map
# Expected: HTTP/2 200
# content-type: application/json

# With invalid token — must return 401
curl -I \
  -H "Authorization: Bearer invalid_token_here" \
  https://maps-internal.example.com/sourcemaps/a1b2c3d4/main.a1b2c3d4.js.map
# Expected: HTTP/2 401

Verification

Run these assertions in CI after deploying the auth-gated endpoint, to guard against regressions:

#!/usr/bin/env bash
# verify-map-auth.sh
BASE="https://maps-internal.example.com/sourcemaps/${RELEASE_VERSION}"
FILE="main.${RELEASE_VERSION}.js.map"

# 1. Unauthenticated request must return 401
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE}/${FILE}")
[ "$STATUS" = "401" ] || { echo "FAIL: expected 401, got $STATUS (no token)"; exit 1; }
echo "OK: unauthenticated → 401"

# 2. Authenticated request must return 200
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer ${MAP_FETCH_TOKEN}" \
  "${BASE}/${FILE}")
[ "$STATUS" = "200" ] || { echo "FAIL: expected 200, got $STATUS (with token)"; exit 1; }
echo "OK: authenticated → 200"

# 3. Public CDN must not serve the .map at all
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
  "https://cdn.example.com/assets/${FILE}")
[ "$STATUS" = "403" ] || [ "$STATUS" = "404" ] || \
  { echo "FAIL: CDN returned $STATUS for .map (expected 403 or 404)"; exit 1; }
echo "OK: public CDN → ${STATUS}"

Edge Cases & Gotchas

  • Clock skew invalidates pre-signed URLs early. S3 pre-signed URLs use the signing time plus the TTL. If the symbolication server’s clock is more than a few seconds ahead of AWS’s time, the URL may expire before the fetch completes. Ensure NTP sync on all hosts; use a 90-second TTL instead of 60 seconds if clock drift is a known issue in your environment.

  • Token rotation leaves in-flight requests unauthenticated. If you rotate MAP_FETCH_TOKEN while symbolication is in progress, any requests using the old token will receive 401. Use a two-token overlap window: add the new token to VALID_TOKENS first, give all systems 60 seconds to pick up the new token, then remove the old one.

  • Browser DevTools cannot use auth-gated maps. A Bearer token requirement means DevTools will never auto-load the map (it doesn’t know how to authenticate). This is intentional for production. For debugging a specific production issue internally, generate a short-lived signed URL manually and load it in DevTools via Sources → Add source map….

  • CI robot tokens vs. human tokens. Keep a separate token (or IAM role) for CI pipelines and one for the runtime symbolication service. This lets you rotate the CI token after a pipeline credential leak without interrupting live error symbolication.

FAQ

Can I use this pattern with Sentry Cloud (not self-hosted)? Sentry Cloud uploads maps at CI time via sentry-cli and stores its own copy — it never fetches maps from your endpoint at runtime. The auth-gated endpoint pattern applies when your error tracker performs on-demand HTTP fetches. For Sentry Cloud, the simpler approach is uploading maps directly via sentry-cli during CI and never exposing them via HTTP at all.

What token format should I use — opaque string or JWT? For machine-to-machine symbolication, an opaque random token (256-bit hex, stored in a secrets manager) is simpler and equally secure. Use a JWT only if you need the token to carry claims (e.g. which release versions the caller may access) or if you want to verify tokens without a network round-trip to the validator.

Does adding auth_request add latency to every map fetch? Yes — each map request incurs one subrequest to the token validator. In practice, the validator returns in under 5 ms on a local socket, which is negligible compared to the S3 fetch itself. Cache the auth decision in nginx’s proxy_cache keyed on the Authorization header value for a 30-second TTL to eliminate the subrequest for repeated fetches from the same service.