Every single-page app ships a map of its own backend to every visitor. The router knows about pages the menu never links to. The API client knows about endpoints the UI only calls for admins. Sometimes a source map hands over the original, unminified source with the folder structure intact.
Reading that map is one of the highest-value things you can do in the first hour of a web test. It's also where a lot of weak reports come from, because a path you found in a bundle is a lead, not a vulnerability. This article covers both halves: how to pull the interesting parts out of a JavaScript bundle, and how to prove which ones actually matter.
Only do this against applications you own or are explicitly authorized to test. Everything below uses stand-in targets like app.example.com.
What's actually inside a bundle
Bundlers (webpack, Vite, the Next.js build and friends) take your source, minify it and split it into chunks. Minification renames variables and strips whitespace. It doesn't remove string literals, so everything the code needs at runtime is still there in plain text:
-
Client-side routes, including ones for pages with no link in the UI (
/admin/reports,/beta/billing). - API calls: base URLs, versioned paths, HTTP methods, the parameter names a request body is built from.
- Feature flags and config: staging hostnames, internal service URLs, toggles for unreleased features.
- Keys and tokens, some of them meant to be public and some of them not.
OWASP's Web Security Testing Guide covers exactly this in WSTG-INFO-05: review the JavaScript for API keys, internal IPs, sensitive routes and credentials, and check whether source map files are exposed.
Source maps, briefly
A source map is a JSON file that maps positions in the minified output back to the original files, so stack traces and debuggers make sense. The format is now standardized as ECMA-426. Two parts of it matter here:
-
How a bundle points at its map. Either a comment at the end of the file (
//# sourceMappingURL=main.4f2a1c.js.map) or aSourceMapHTTP response header. Per the spec, the header takes precedence over the comment. -
What's in the map. The
sourcesfield lists the original file paths. The optionalsourcesContentfield can embed the original source of every file, comments and all.
So a public .map file with sourcesContent turns "minified soup" into a readable copy of the frontend codebase. That's not automatically a vulnerability (the frontend runs in the browser anyway), but it makes everything else in this article much faster, and it occasionally contains things that were never supposed to leave the repo.
Step 1: Collect every script
Browse the app the way a normal user would, then the way your highest-privileged test account would, with a proxy (Burp, ZAP, Caido) or the browser's DevTools Network tab recording. Filter by JS and save everything. Lazy-loaded chunks only appear when you visit the page that needs them, so click into every section you can reach. With code splitting, the main bundle usually has to know the file names of the chunks it may load later, so it's worth searching it for chunk names too: sometimes that leads you to code for pages your account never renders.
Step 2: Check for source maps
For each script, check three things:
- Does the file end with a
sourceMappingURLcomment? - Does the response carry a
SourceMapheader? - Does the same URL with
.mapappended answer? WSTG suggests exactly this: if you see/static/js/main.chunk.js, try/static/js/main.chunk.js.map.
The third check matters because some build setups write the map file but drop the comment (more on that in the fix section). From the command line, against your stand-in target:
# Is there a SourceMap header?
curl -sI https://app.example.com/static/js/main.4f2a1c.js | grep -i '^sourcemap'
# Does a .map file answer, and is it JSON?
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
https://app.example.com/static/js/main.4f2a1c.js.map
If a map loads, open it in DevTools (the Sources panel will show the original file tree) or read its sources list directly. The file paths alone tell you a lot: module names like adminApi.js or exportService.ts are a reading list.
Step 3: Extract the leads
You can grep by hand, but two open-source tools do this well, and both are mentioned in the WSTG section above:
-
jsluice from Bishop Fox parses the JavaScript (it uses tree-sitter rather than regex alone) and looks for places URLs are actually used, like
fetch()calls anddocument.locationassignments.jsluice urls main.jslists URLs with their query and body parameters, andjsluice secrets main.jslooks for key-like values. -
LinkFinder is a Python script that finds endpoints and parameters with a set of regular expressions.
python linkfinder.py -i main.js -o cliprints them to the terminal.
If you want to see how little magic is involved, here's a small, dependency-free version you can run on files you've saved. It finds the source map link, quoted strings that look like API paths or app routes, and a couple of well-known key prefixes:
import re
import sys
from pathlib import Path
# Quoted strings that look like API paths or app routes
PATH_RE = re.compile(r"""["'`](/(?:api|v\d+|admin|internal|graphql)[A-Za-z0-9_\-/.{}$:?=&]*)["'`]""")
# The source map link the spec defines for JavaScript
MAP_RE = re.compile(r"//[#@]\s*sourceMappingURL=(\S+)")
# A few well-known key prefixes worth a second look
KEY_RE = re.compile(r"\b(sk_live_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16})\b")
for name in sys.argv[1:]:
text = Path(name).read_text(encoding="utf-8", errors="replace")
print(f"== {name}")
for m in MAP_RE.finditer(text):
print(f" source map : {m.group(1)}")
for path in sorted(set(PATH_RE.findall(text))):
print(f" path : {path}")
for key in sorted(set(KEY_RE.findall(text))):
print(f" key-like : {key[:12]}... (check what it is before anything else)")
Run against a stand-in bundle (python bundle_leads.py js/*.js), the output looks like this:
== js/main.4f2a1c.js
source map : main.4f2a1c.js.map
path : /admin/reports
path : /api/v2
path : /api/v2/orders/${id}
path : /api/v2/users/me
path : /internal/export/users?format=csv
key-like : sk_live_XXXX... (check what it is before anything else)
It's deliberately simple: it misses paths built from variables, which is exactly where jsluice is better. Use it to understand the idea, then use the real tools.
Turn the list into a short list
A raw extraction from a large app can run to hundreds of paths. The useful move is a diff: take everything the bundle knows about, subtract everything your proxy history shows the UI actually called while you browsed with each test account. What's left is functionality the frontend can reach but you haven't exercised: admin routes, export jobs, old API versions, beta features. That short list is where the testing time goes.
Step 4: Prove it, or it's just a list
This is where reports go wrong. "I found /internal/export/users in main.js" is not a finding. The server might reject it, require an admin session, or not have that route at all anymore. Detection isn't proof: what you have is a hypothesis about the server, and the only way to test it is to ask the server.
Endpoints: test authorization with two accounts
Most hidden endpoints matter because of one question: does the server check that this user may call this function? That's OWASP API5:2023, Broken Function Level Authorization, and the OWASP entry is explicit that you shouldn't assume an endpoint is admin-only just because of its path.
Set up two accounts you control on the test environment: a regular user and an admin. Then, for each endpoint on your short list:
- Baseline: call it as the admin (or capture the admin's real request in the proxy). This shows what success looks like.
- Test: replay the same request with the regular user's session. Then with no session at all.
- Compare: did the low-privilege request get the same data or perform the same action? An error page that returns HTTP 200 isn't success; compare the bodies.
POST /internal/export/users?format=csv HTTP/1.1
Host: app.example.com
Authorization: Bearer <regular-test-user-token>
If the regular user gets the same CSV the admin gets, you have a finding, and you have its proof: the two requests side by side, the matching responses, and the user role each token belongs to. If the server returns 403, the route in the bundle was just a route in the bundle. Write it in your notes and move on.
Keep the proof minimal and non-destructive. For a state-changing endpoint, act on data owned by your own test accounts, and stop once you've shown the server accepted the request.
Keys: identify before you report
A key in a bundle is only a finding if it shouldn't be there. Some keys are designed to be public. Stripe's documentation, for example, says only publishable keys are safe to expose outside your back end; a pk_live_β¦ key in a checkout page is expected, while a secret sk_live_β¦ key in client code is not.
So first, identify the key from the provider's own documentation: what type it is, and what that type is allowed to do. Then check your program's rules before you touch it. Some programs don't allow using found credentials at all, and you rarely need to: the key's location, its prefix, and the provider's documented scope for that key type are usually enough evidence. If the rules do allow a check, use the least-impact, read-only call the provider documents for that purpose, and nothing else.
Source maps: severity follows content
An exposed source map on its own is usually informational. Its severity comes from what's inside it: hardcoded credentials in sourcesContent, internal hostnames, or code that reveals a server-side flaw you can then prove the normal way. Report what it enables, with evidence, not the fact that it exists.
How to fix it (for developers)
1. Authorization lives on the server, for every function. This is the real fix. Hiding a route from the menu, or even from the bundle, doesn't protect it. Every endpoint should check the caller's role on the server, deny by default, and have a test that calls it as a regular user and expects a 403.
2. Decide deliberately what ships with your production build.
-
Vite:
build.sourcemapdefaults tofalse. Its'hidden'option still writes the.mapfile and only drops the comment from the bundle. -
webpack:
devtoolhashidden-source-map(same idea: no reference comment, map still generated, meant for error reporting) andnosources-source-map(a map withoutsourcesContent, which maps stack traces without shipping your source). -
Next.js:
productionBrowserSourceMapsis off by default; when you turn it on, the maps are served next to the JS files.
The trap with "hidden" maps: the file is still in your build output. If that folder is deployed as-is, the .map URL still answers (that's Step 2, check 3). If you want maps for error tracking, upload them to your error tracker during the build and delete them from the directory you deploy.
3. Keep secrets out of client code. Anything in the bundle is public. Server-side keys belong on the server, and a backend endpoint should do the privileged call on the user's behalf.
4. Make it a build check. A few lines in CI catch regressions:
# Fail the build if source maps or secret-looking keys reach the deploy folder
if find dist -name '*.map' | grep -q .; then echo "source maps in dist/"; exit 1; fi
if grep -rEq 'sk_live_[A-Za-z0-9]{8,}' dist; then echo "secret-looking key in dist/"; exit 1; fi
Adjust the folder and patterns to your stack; a dedicated secret scanner covers far more key formats than one regex.
Try it yourself
Two legal practice targets fit this technique well:
- PortSwigger Web Security Academy: Unprotected admin functionality with unpredictable URL. The admin panel isn't linked anywhere, but its location is disclosed in the page's JavaScript. Find it by reading the code, not by guessing.
-
OWASP Juice Shop, run locally:
docker run --rm -p 127.0.0.1:3000:3000 bkimminich/juice-shop. The Score Board and Admin Section challenges are both pages the UI never links to. Save the app's JavaScript, run jsluice or the script above, and see what the route list tells you. Then, for anything you find, do Step 4 properly: what does the server let a regular user do?
Conclusion
A JavaScript bundle is the most honest documentation an app has: it lists what the frontend can do, whether or not the UI shows it. Collect every script, check for source maps three ways, extract the routes and keys, and diff them against what you've actually seen called. Then treat every item as a question for the server, answered with two accounts and side-by-side requests. The bundle gives you leads. Only the server's response gives you a finding.
Further reading
- OWASP WSTG-INFO-05: Review Web Page Content for Information Leakage
- ECMA-426: Source map format specification
- OWASP API5:2023 Broken Function Level Authorization
- jsluice (Bishop Fox) and LinkFinder
- Build docs: Vite
build.sourcemap, webpackdevtool, Next.jsproductionBrowserSourceMaps - Stripe: API keys
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
π If this was useful, follow me for more hands-on web & API security write-ups.
π Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/

Top comments (0)