
Hardcoded Keys in React Native Bundles: Scanning Your Own Artifacts the Way Claude Scanned 1.8 Million APKs
Why a React Native Bundle Is the Highest-Signal File in Your APK
I have torn apart a lot of APKs, and what keeps surprising me is how thin the wrapper really is. An APK is a zip file. An IPA is a zip file. Build with React Native and one entry in that zip is a JavaScript bundle packed with string literals — and some of those literals are hardcoded keys that were never meant to leave your machine.
This post walks through why those keys land in the artifact, how to extract and scan your own APK, IPA, and JS bundle for them, and which fixes actually stop the exposure.
The campaign reported this week is not interesting because "AI can find secrets." Grep finds secrets. It is interesting because triage — picking the live credential out of 40,000 candidate strings across 1.8 million artifacts — used to be the expensive part, and now it is not.
What the reported Android app scan actually claims
The gHacks piece published 2026-09-13, attributed to a disclosure by Anthropic, describes attackers abusing Claude to scan 1.8 million Android apps for embedded secrets. That is the claim as reported. I have not read the Anthropic disclosure directly — only the coverage — so treat the scale, the attribution, and the tooling as reported rather than something I verified myself.
Why hardcoded keys land in React Native bundles specifically
Native code is annoying to read. A .dex file or a stripped .so gives you symbol fragments and constant pools. A JavaScript bundle hands you readable string literals, module boundaries, and sometimes the variable name the value was assigned to. React Native ships assets/index.android.bundle inside the APK and Payload/YourApp.app/main.jsbundle inside the IPA. For anyone hunting secrets, that is the highest-signal file in the artifact.
The thesis in one line: do not ship the key
If a model can triage 1.8 million artifacts for a few cents each, the only control that still works is not shipping the key.
Confirmed Facts Versus What I Could Not Verify
What the public reporting establishes and what it does not
The reporting establishes a large-scale scan of Android apps, use of a commercial LLM, and an Anthropic disclosure. It does not give me a CVE, a victim list, a list of extracted keys, or any tooling detail. There is no product advisory to point at, which means everything past "secrets leak from mobile artifacts" in this post is a general property of shipping builds, not a detail of that campaign.
Framing rule for the rest of the post
| Claim type | How I treat it |
|---|---|
| The 1.8M-app campaign | Reported, attributed to Anthropic via gHacks, not verified by me |
| React Native artifact paths and string visibility | Verified in my own builds, commands shown below |
| Scanner behavior and false positives | Observed output, specific to the versions I used |
| "Attackers used prompt X" | Unknown, and I am not going to invent it |
The Economics of LLM-Assisted Secret Hunting
Regex and entropy scanners were never the expensive part
gitleaks, trufflehog, and a five-line regex script have been free for years. Detection was never the bottleneck — the review queue was. A 12,000-line report of "entropy greater than 4.0" is unreadable, so teams either ignore it or tune it until it catches almost nothing.
Why app store catalogs are an ideal corpus for secret scanning
App store catalogs are a bulk-downloadable pile of static files. No per-target authentication, no rate-limited API to respect while you analyze, no server to keep alive, and the artifact does not change while you look at it. Scanning live web apps is the opposite problem: every candidate is an authenticated request you have to justify.
What humans, triage models, and neither catch
| Signal | Human reviewer | Triage model | Both miss |
|---|---|---|---|
sk_live_…, AKIA…, ghp_… | Catches | Catches | — |
| Unlabeled 32-char hex (Algolia-style) | Usually skips | Ranks by context | Keys with no surrounding context |
| Is this key actually privileged? | Strong | Weak without docs | Unrestricted-but-"public" keys |
| Value split across concatenated string literals | Sometimes | Rarely | Anything not assembled statically |
That last row is where I would put defensive effort — and it is also where most vendors sell a false sense of completeness.
Why React Native Bundles Leak Keys
Build-time substitution is the root cause
Three patterns bake values into the artifact:
react-native-configreads.envat build time and generates native build config (BuildConfig.javaon Android, an.xcconfigon iOS). The values reach you through a native module at runtime, but the strings themselves live in the compiled output.babel-plugin-transform-inline-environment-variablesandbabel-plugin-transform-defineswapprocess.env.Xreferences for literals during Metro's transform step. You end up with a plain string in the bundle.- Hand-rolled codegen that writes a
config.jsfrom.envbefore bundling. Same outcome, fewer guardrails.
The react-native-config docs are explicit that these values are compiled into the app and should be treated as build inputs, not a vault. That is a property of the approach, not a bug in the library.
The hardcoded keys I find most often in React Native bundles
In rough order of how often I see them: Google Maps API keys, Firebase config (google_api_key, default_web_client_id, google_app_id, now usually materialized into res/values/strings.xml), Sentry DSNs, Algolia application IDs paired with an admin key someone copied from the wrong dashboard tab, Stripe publishable keys, and — worst — Supabase service role JWTs, which bypass row-level security entirely.
They are not all equal. Google documents Firebase API keys as project identifiers rather than secrets; the real control is your security rules and App Check. Maps keys need to be restricted by package name and signing certificate SHA-1. A sk_live_ key, a Supabase service role key, or an AWS access key ID in a bundle is a different category of problem, and that distinction is exactly what a triage model is bad at judging without your architecture in context.
Hermes bytecode is not protection
Hermes compiles JavaScript to bytecode, but the string table is not encrypted. ASCII literals are stored one byte per character, so strings finds them directly. Some strings are UTF-16, which is why I grep the disassembly too instead of trusting strings alone. "We compile to Hermes" is a performance decision, not a security control, and I have seen it written into more than one threat model as though it were one.
Source maps and unstripped debug builds
Release builds do not include source maps by default. They leak when teams upload them to crash-reporting services with public read access, or leave them lying around in CI artifacts. A source map next to a bundle turns string hunting into reading your own code with your own variable names.
Scanning Your Own APK, IPA, and JS Bundle — A Reproducible Pass
Extract the APK and locate the bundled JavaScript
$ unzip -o -q app-release.apk -d apk-extracted
$ du -sh apk-extracted
14M apk-extracted
$ find apk-extracted -name "*.bundle" -o -name "*.jsbundle"
apk-extracted/assets/index.android.bundle
$ xxd -l 8 apk-extracted/assets/index.android.bundle
00000000: c61f bc03 0000 0000 ........
Those first four bytes are the Hermes magic. If you see var __BUNDLE_START_TIME__ or a function( on the first line instead, you shipped a plain-text bundle — which is worse, because no decompilation step is required at all.
Pull the main.jsbundle out of an IPA
$ unzip -o -q MyApp.ipa -d ipa-extracted
$ ls -la ipa-extracted/Payload/MyApp.app/main.jsbundle
-rw-r--r-- 1 me staff 5107740 main.jsbundle
Note the filename: it is still main.jsbundle even when the contents are Hermes bytecode.
Convert Hermes bytecode to searchable output and grep it
hbctool and hermes-dec both work here; I used hermes-dec (HBC v96, RN 0.72-era build):
$ hbc-disassembler apk-extracted/assets/index.android.bundle out.hasm
$ wc -l out.hasm
418233 out.hasm
$ grep -n "AIza" out.hasm | head -3
Line 188402: "AIzaSyD[...redacted]"
$ grep -c "service_role" out.hasm
1
The service_role hit is the one that matters. That is a Supabase key with full table access, sitting in a shipped bytecode string table, found with a single grep.
Run gitleaks and trufflehog over the extracted tree
$ gitleaks dir ./apk-extracted --report-format json --report-path gl.json --no-banner
WRN leaks found: 2
$ jq -r '.[] | "\(.RuleID)\t\(.File)\t\(.StartLine)"' gl.json
stripe-access-token assets/index.android.bundle 1
generic-api-key assets/index.android.bundle 1
$ trufflehog filesystem ./apk-extracted --json | jq -r '.DetectorName' | sort -u
GoogleMapsAPIKey
Stripe
Two caveats I want to be explicit about. Both tools are line-oriented, so on a Hermes binary the "line number" is meaningless, and neither found anything inside classes.dex in my run even though react-native-config values were there. The Supabase key was not flagged by either tool — the JWT it produced did not match a detector — which is why I keep a small custom script in the release job.
#!/usr/bin/env python3
PATTERNS = [
("google-api-key", re.compile(rb"AIza[0-9A-Za-z_\-]{35}")),
("aws-access-key-id", re.compile(rb"(?:AKIA|ASIA)[0-9A-Z]{16}")),
("stripe-secret", re.compile(rb"sk_live_[0-9a-zA-Z]{16,}")),
("github-pat", re.compile(rb"ghp_[A-Za-z0-9]{36}")),
("slack-token", re.compile(rb"xox[abprs]-[0-9A-Za-z-]{10,}")),
("jwt", re.compile(rb"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}")),
]
def entropy(data):
if not data:
return 0.0
counts = {}
for byte in data:
counts[byte] = counts.get(byte, 0) + 1
n = len(data)
return -sum((c / n) * math.log2(c / n) for c in counts.values())
def scan(root, min_entropy=3.5, min_len=20):
findings = 0
for path in sorted(pathlib.Path(root).rglob("*")):
if not path.is_file():
continue
data = path.read_bytes()
for name, rx in PATTERNS:
for m in rx.finditer(data):
print(f"[high] {path}:{name} off={m.start()} entropy={entropy(m.group()):.2f}")
findings += 1
if b"service_role" in data:
print(f"[high] {path}: supabase-service-role claim present")
findings += 1
for tok in re.finditer(rb"[A-Za-z0-9+/=_\-]{%d,}" % min_len, data):
if entropy(tok.group()) >= min_entropy:
print(f"[low] {path}: high-entropy-string off={tok.start()} entropy={entropy(tok.group()):.2f}")
findings += 1
print(f"{findings} findings")
return 0 if findings == 0 else 1
if __name__ == "__main__":
sys.exit(scan(sys.argv[1]))Observed on my own release build: two high-confidence keys, one service_role claim, and nine low-severity entropy hits that were all build hashes.
What a Clean Scan Actually Proves
Limitations I hit with static bundle scanning
- Short keys and unlabeled random strings under my 20-character minimum never surface.
- A key assembled at runtime from fragments (
["sk","_live","_",…].join("")) is invisible to every static scanner, mine included. - Base64-wrapped values need a decode-and-rescan pass I did not add.
- Keys inside prebuilt third-party
.sofiles are technically greppable but produce so much noise that I excluded them, which means that surface is not covered.
Entropy thresholds produce false positives
A 32-character hex token tops out at 4.0 bits per character, so any threshold low enough to catch it also catches UUIDs and build IDs. I keep the entropy pass at low severity and never let it fail the build on its own.
A scan gate is a regression detector, not a proof
The honest framing: a passing scan means "we did not reintroduce the patterns we know about." It does not mean the artifact is secret-free.
Fixing It — Commit, Artifact, Runtime
Commit stage: keep the key out of git
A pre-commit hook plus a CI job running gitleaks git on the diff stops the key from reaching main. Cheapest gate, and the one most teams already have.
Build stage: gate the extracted artifact
Add gitleaks dir and your custom script against the extracted release artifact, and make the job fail on high findings. Source scanning cannot catch values that only appear after Metro inlines them or after Gradle writes BuildConfig.java. The artifact is the thing that ships, so the artifact is the thing you gate.
Runtime stage: remove the shipped credential
- Proxy third-party calls through your backend so the credential never ships.
- Use short-lived, per-user tokens instead of one shared key for all installs.
- Restrict Maps keys by package name and signing certificate SHA-1, and enable Firebase App Check.
- Audit Supabase RLS policies and confirm no
service_rolekey exists in client code at all.
Rotation discipline
A leaked key that was never rotated is still live, and published binaries get mirrored and indexed indefinitely. If you find a sk_live_ key in an old build, treat rotation as mandatory whether or not you can prove abuse.
What I would fix first, and why
If I could fix only one thing: stop shipping baked shared secrets, and move those calls behind your backend. Second, add artifact-level scanning so it does not come back. Third: never treat obfuscation as a control. I would rather spend the effort on a build gate that fails loudly than on string encryption that a determined reader decodes in an afternoon.
What I Confirmed And What I Did Not Test
Confirmed in my own runs
Bundle paths inside APK and IPA, the Hermes magic header, greppable string literals in a release bytecode file, the service_role claim being present in shipped bytecode, and the output shapes of gitleaks dir and trufflehog filesystem on these artifacts.
What I did not test
The Claude-based campaign, the actual Anthropic disclosure, any third-party application, and any store-side detection or takedown behavior. I also did not measure how well a model triages a real 40,000-candidate result set — that claim is inference from published capability, not a benchmark I ran.
Conclusion — Assume Every Artifact You Publish Will Be Scanned
The real lesson from the reported campaign
The lesson is not "AI is dangerous." It is that your build output was always public, and cheap triage only removed the excuse that nobody could read it. The economics changed; the exposure did not.
One action this week: scan release artifacts in CI
Add artifact-level secret scanning to the release job: extract the APK or IPA, run gitleaks dir plus a custom regex pass, and fail the build on high-confidence findings. It is an afternoon of work, and it is the only gate that sees the exact bytes your users download.


