Every modern web app sends its front-end code to every visitor. That code has to know how to call the API, so it carries a map of the backend: routes, parameters, roles, feature flags, and sometimes keys that were never meant to leave the build server. Reading it carefully is one of the highest-return habits in bug hunting.
This post covers how we collect and read JavaScript at SecureDX on pentests and bug bounty programs. Every example uses example.com. Only test targets you are authorized to test.
- 01Collectkatana · gau · chunks
- 02Source mapsrebuild original code
- 03Endpointsroutes · params · hosts
- 04Secretstriage before reporting
- 05Logicroles · flags · validation
- 06Watchdiff routes every build
Why bundles leak so much
A single-page app built with React, Vue or Angular is compiled into a few large JavaScript files. To work, those files must contain everything the app might ever do in the browser, for every type of user. The server decides who may do what; the bundle just knows how to ask.
What a typical production bundle contains:
| What | Example | Why it matters |
|---|---|---|
| API routes | /api/v2/admin/users/{id}/role |
Endpoints the UI never shows to normal users |
| Base URLs | https://internal-api.example.com |
Hosts that never appear in DNS searches |
| Parameter names | includeDeleted, asUser, debug |
Inputs to try that the UI never sends |
| Role and permission names | isAdmin, canExport, role === "support" |
How the app thinks about access |
| Feature flags | enableNewBilling, beta_reports |
Unfinished features, often with weaker checks |
| Third-party keys | Analytics IDs, map keys, payment publishable keys | Usually public by design, occasionally not |
| Comments and strings | // TODO: add auth check, error messages |
Developer intent, in their own words |
Minification shortens variable names, but it can’t shorten URLs, parameter names or strings the server expects. Those survive intact, and they’re what you’re after.
Step 1: Collect every JavaScript file
The files your browser loads on the homepage are only part of the app. Collect from three places: what a crawler finds today, what archives saw in the past, and the lazy-loaded chunks that only load when a user opens a certain screen.
# JS referenced by live pages (-jc also parses JS files for more links)
katana -u https://app.example.com -jc -d 3 -rl 20 -silent | grep -iE '\.js(\?|$)' > js_crawl.txt
# JS seen in archives, including old versions
gau app.example.com | grep -iE '\.js(\?|$)' > js_history.txt
# Merge, keep the ones that still respond
cat js_*.txt | sort -u | httpx -silent -mc 200 > js_live.txt
# Download, named by content hash so duplicates collapse
mkdir -p js && while read -r u; do
f=$(curl -s "$u" | tee /tmp/body.js | sha1sum | cut -c1-12)
mv /tmp/body.js "js/$f.js"; echo "$f $u" >> js_index.txt
done < js_live.txt
Lazy-loaded chunks. Webpack and Vite split apps into chunks loaded on demand, so a crawler that never opens the admin screen never sees the admin chunk. The main bundle usually holds a map of chunk IDs to file names. Search it for the chunk loader and for file name patterns:
grep -oE '"static/js/[a-zA-Z0-9_.-]+\.chunk\.js"' js/*.js | sort -u
grep -oE 'assets/[a-zA-Z0-9_-]+\.js' js/*.js | sort -u
Build the full URLs from those names and add them to your download list.
Archived versions are worth keeping separately. An old bundle can reference endpoints that were removed from the UI but are still running on the server.
Step 2: Look for source maps
A source map links minified code back to the original source files, so developers can debug production errors. When one is published, you get the original code: real variable names, the folder structure, and comments.
Find them in two ways:
# The bundle names its map in a comment on the last line
tail -c 300 js/*.js | grep -o 'sourceMappingURL=[^ ]*'
# Or try the .map URL directly for every bundle
sed 's/$/.map/' js_live.txt | httpx -silent -mc 200 -o maps_live.txt
Then rebuild the source tree:
sourcemapper -url https://app.example.com/static/js/main.4f2a.js.map -output src/
What to read first in the rebuilt source:
- The folder structure. Names like
src/admin/,src/internal/orsrc/support-tools/show whole areas of the app. - API client files such as
api.tsorservices/. They list every call in one place, with parameter types. - Route definitions. The router config shows every page and which role guard, if any, protects it.
- Comments.
TODO,FIXME,HACKandtemporarymark the places developers knew were unfinished.
An exposed source map is usually a low-severity finding on its own. Its real value is the code it gives you to read.
Step 3: Pull out endpoints and parameters
Regex finds strings that look like paths. A JavaScript-aware parser does better: it understands string concatenation and template literals, so it can report /api/users/${id}/export as one route with a variable in it.
# Routes and URLs, parsed from the code structure
for f in js/*.js; do jsluice urls "$f"; done | jq -r '.url' | sort -u > js_routes.txt
# Quick regex pass for API-looking paths jsluice may miss
grep -rhoE '/(api|v[0-9]+|graphql|internal|admin)/[A-Za-z0-9_/{}$.-]*' js/ | sort -u >> js_routes.txt
# Base URLs: other hosts the app talks to
grep -rhoE 'https?://[a-zA-Z0-9.-]+\.example\.com' js/ | sort -u > js_hosts.txt
Turn the raw list into a route table you can work through:
| Route | Method | Parameters | Seen in UI? | Notes |
|---|---|---|---|---|
/api/v2/orders/{id} |
GET | id |
Yes | Test with a second account’s ID |
/api/v2/admin/users/{id}/role |
PUT | id, role |
No | Admin-only route, test with a normal session |
/api/v1/export |
POST | format, includeDeleted |
No | Old version, unused flag |
The “Seen in UI?” column is the one to sort by. Routes the interface never calls are the least tested, because nobody clicking through the app ever reaches them.
GraphQL apps keep their queries and mutations as strings in the bundle. Search for query and mutation to list every operation the app knows, with field names, even when the server has introspection turned off.
grep -rhoE '(query|mutation) [A-Za-z0-9_]+' js/ | sort -u
Step 4: Triage secrets carefully
Secret scanners match patterns, so most hits in front-end code are keys that are meant to be public. Reporting those wastes the triager’s time and costs you reputation. Know which is which before you write anything up.
# Candidate secrets, parsed with context
for f in js/*.js; do jsluice secrets "$f"; done | jq -c '{kind, data}' | sort -u > js_secrets.txt
| Key type | Looks like | Usually a finding? | What decides it |
|---|---|---|---|
| Firebase web config | apiKey: "AIza...", projectId |
No | Public by design; check whether the database and storage rules allow anonymous access instead |
| Google Maps key | AIza... |
Rarely | Only if it has no referrer or API restrictions, and the program accepts that class |
| Stripe publishable key | pk_live_... |
No | Meant for the browser |
| Stripe secret key | sk_live_... |
Yes, critical | Never belongs in front-end code |
| AWS access key | AKIA... |
Yes, if valid | A key ID with its secret in the same file is serious |
| Slack or Discord webhook | hooks.slack.com/services/... |
Often | Anyone can post into the company’s channels |
| Private API tokens | Bearer ..., long random strings near an auth header |
Often | Depends on what the token can access |
| Analytics and tag IDs | G-..., GTM-... |
No | Public, but useful for finding sister sites |
When a key looks real, prove it with the least invasive request possible: one read-only call that shows whose account it belongs to, such as an identity check. Never read data, send messages or change anything with it. Stop, record what you saw, and report it quickly so the company can rotate it.
Step 5: Read the logic, not just the strings
Endpoints tell you where to look. The code around them tells you what the developers assumed, and bugs live in wrong assumptions. Open the bundle in a formatter (your browser’s DevTools “pretty print” works) and search for these:
- Role checks in the browser. Code like
if (user.role === "admin") showDeleteButton()hides a button, but the server must also refuse the request. Call the route the button would call, with a normal user’s session. If the server accepts it, the only protection was the hidden button. - Feature flags. Names like
enableBulkExportorbeta_show features that are built but switched off. A flag that only hides the UI often leaves the API live. - Debug and internal switches. Parameters such as
debug=true,asUser,impersonateormocksometimes still work in production. - Client-side validation. Limits enforced only in JavaScript (file types, price fields, quantity caps) may not be enforced again on the server.
- IDs the client sends. If the app sends
userIdoraccountIdin a request body instead of relying on the session, test whether the server checks it belongs to you.
Search terms that pay off in formatted bundles: isAdmin, role, permission, feature, flag, debug, internal, TODO, deprecated.
Test each idea with two accounts you control. Proving that one of your accounts can read or change the other’s data is enough to show impact, without touching any real user’s information.
Step 6: Watch bundles change
A changed bundle usually means a new release, and new releases bring new routes and features that nobody has tested yet. Track the route list, not the raw files: bundle names change on every build even when the code barely does.
#!/usr/bin/env bash
# js-watch.sh: alert on routes that appear in a new build
cd ~/recon/app.example.com
# Re-collect and re-extract (steps 1 and 3)
./collect_js.sh && ./extract_routes.sh # writes js_routes.txt
# Keep only routes never seen before, and remember them
anew routes_seen.txt < js_routes.txt > new_routes.txt
[ -s new_routes.txt ] && notify -silent -bulk -data new_routes.txt
Run it daily. When a new route appears, read the chunk it came from, add it to your route table, and test it first. You’ll often be looking at a feature hours after it shipped, before it has been tested by anyone else.
For developers: what not to ship
Everything above works because front-end code is public. You can’t hide it, so make sure nothing in it matters if read.
- Turn off production source maps, or upload them only to your error-tracking service.
- Keep secret keys on the server. Only keys designed for browsers (publishable, referrer-restricted) belong in front-end code. Add secret scanning to CI so a
sk_live_key fails the build. - Enforce every permission on the server. Hiding a button is a convenience, not access control. Each API route checks the session’s role and that the requested object belongs to that user.
- Split admin code into a separate bundle, served only to logged-in admins.
- Remove dead routes and flags from both the front end and the API when a feature is retired.
- Never trust IDs sent by the client. Take the user and account from the session.
- Strip comments in production builds; most bundlers do this by default, so check yours does.
Next: Part 2: Advanced Techniques covers route manifests, webpack chunk recovery, AST parsing and per-role traffic recording.
SecureDX tests web apps manual-first, starting with exactly this kind of JavaScript review. If you want your app checked, get in touch at securedx.in/contact. Hunters who work this way can request an SDX Society invite at securedx.in/society.
Loading comments…