If you are building a React Native app with Cursor and a security scan, a teammate, or your own audit flagged that the OpenAI key, the Supabase service role key, or the Stripe secret key is sitting in a screen file, this article walks through how the pattern emerges, why the production bundle still carries the value, and the structural change that gets the key out of the binary for good.
Short answer
The short answer is that Cursor inlines the literal because the model treats the key it can see (in your prompt, in an open buffer, in .env) as the simplest way to make the API call succeed, and React Native then compiles that literal straight into the JavaScript bundle that ships with the AAB or IPA. The Expo environment variables guide is explicit: any value with the EXPO_PUBLIC_ prefix is inlined into the client bundle and visible in plain text in the compiled application. OWASP tracks the wider pattern as MASWE-0005, API Keys Hardcoded in the App Package, under the MASVS-AUTH category. The fix is structural: keep production secrets behind a backend proxy, set explicit rules at .cursor/rules that forbid client-side keys, and scan the build for any literal that looks like a token before you upload it.
What you should know
- Cursor writes the key it can see. If a real secret is in your prompt, in an open file, or in the .env Cursor reads as context, the model is likely to inline it because that produces code that runs on the first try.
- Metro inlines
process.env.EXPO_PUBLIC_*. The variable is replaced with the literal at build time, so the value lives insideindex.android.bundleand the iOS equivalent. react-native-configis not a vault. The official npm page states the module does not obfuscate or encrypt secrets for packaging, and that you should not store sensitive keys in.env.- A mobile binary is a public artifact. Any value that reaches the shipped APK, AAB, or IPA should be treated as visible to anyone with the file and a hex editor.
- OWASP has a name for the pattern. MASWE-0005 covers hardcoded API keys; MASTG tests 0212, 0213, and 0214 cover both code and binary file storage on iOS and Android.
How does Cursor end up putting an API key into the bundle in the first place?
The short answer is that the model takes the simplest path that returns a working response. When you ask Cursor to add a feature that calls an external API, the agent reads the surrounding files for context. If your prompt contains the literal key, or your .env has a value the agent already loaded into context, the cheapest valid program writes the key directly into the call. That program runs on the first attempt, the test passes, and Cursor moves on.
The second path is documentation cargo. A 2025 DEV Community study that scanned 100 Cursor-built apps reported that roughly two-thirds carried at least one critical issue, with hardcoded credentials appearing in a substantial share. The pattern reflects how large language models reach for working examples: a snippet from OpenAI's quickstart, a sample from a Stripe blog post, or a snippet from a public GitHub project. The model imports the shape of the example, including the place the key sits.
The third path is React Native's own conventions. Frameworks that compile a JavaScript bundle blur the line between configuration and code, so a variable that reads as private in a Node server context becomes a string baked into the binary on a mobile device. Cursor does not have a built-in check that distinguishes the two contexts, so the same generation strategy that is safe on a server is unsafe in an Expo project.
Which keys actually leak when you build a React Native app with Cursor?
The short answer is that any string the agent passes to process.env.EXPO_PUBLIC_* or hardcodes into a .ts or .tsx file under the React Native project lands inside the production bundle. The Expo documentation puts it this way: "Do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables. These variables will be visible in plain-text in your compiled application."
The keys that cause the most damage in practice fall into three groups.
- Provider-secret keys that grant full account access: OpenAI keys (
sk-...), Stripe secret keys (sk_live_...), Supabase service role keys (the JWT that starts witheyJhbGc...and carriesservice_role), AWS IAM access keys, and Anthropic API keys. Any of these in a bundle is a high-severity finding. - Backend signing material: JWT signing secrets, SSO client secrets, webhook signing secrets. These are not for a client, ever.
- Third-party integration tokens that the provider treats as private: SendGrid, Twilio, Mapbox secret tokens (as distinct from the public access token), and PostHog personal API keys.
Keys that are designed to be client-facing (an Expo public web URL, a Google Maps API key that is restricted by bundle ID and SHA-256 fingerprint, a Supabase anon key with row-level security in place, a Firebase config object) are still visible in the bundle, but the provider has shipped a model where that visibility is acceptable. The work then moves to enforcing the restrictions the provider gives you (referrer or bundle restrictions, RLS policies, App Check), not hiding the value.
How do I find the hardcoded keys Cursor already shipped?
The short answer is to scan the build artifact itself, not just the repository, because Metro will inline values that are only present in .env and never appear in a tracked source file.
The practical procedure has four steps.
- Decode the bundle. On Android, unzip the AAB or APK, then either run
bundletoolto extract the universal APK or useapktool d app-release.apkto recover the bundled assets. The minified JavaScript lives inassets/index.android.bundle. On iOS, unzip the IPA and look inside the.appdirectory formain.jsbundle. - Run a secret scanner against the bundle directory. Both trufflehog and gitleaks accept a filesystem path. Most providers publish high-entropy patterns these tools already detect.
- Manually grep for the prefixes you know are in use. Common ones:
sk-,sk_live_,pk_live_,eyJhbGc,AIza,xoxb-,ASIA,AKIA,ghp_,glpat-. The result tells you both the provider and the type of key. - If a real secret turns up, rotate it before you do anything else. The clock starts at the upload, not at the discovery.
The same procedure works on a build that is already in TestFlight or in Google Play internal testing. The OWASP MASTG iOS test MASTG-TEST-0214 covers the wider pattern of hardcoded keys in files, and MASTG-TEST-0212 and MASTG-TEST-0213 cover keys in code on Android and iOS respectively.
What should the secret pattern look like for a React Native app?
The short answer is that secrets belong on a server you control, and the client gets a short-lived token or a proxied endpoint. That structure removes the inline question entirely: there is no key to write into the bundle, because the client never holds one.
The pattern looks like this. A simple backend (Cloud Functions, Supabase Edge Functions, an Express service on Fly.io, a Next.js route on Vercel) holds the long-lived provider key. The React Native app authenticates the user (Sign in with Apple, Supabase auth, Clerk), then calls your backend with the user's session. The backend validates the session and calls the third-party API on the user's behalf. The response flows back to the client. The user sees the same behavior; the secret never leaves your server.
For third parties that require a client-side call (Stripe payment sheets, push notification SDKs, analytics), use the keys the provider published as client-safe: the Stripe publishable key (pk_live_...), the Firebase config object protected by App Check, the Supabase anon key with row-level security enabled. The Supabase documentation on the service_role key is explicit that it must never be used in a client because it bypasses every row-level security policy.
How do I stop Cursor from adding the keys again next session?
The short answer is to give the agent a rule file that explicitly bans the pattern and a tool that catches the rare slip before it reaches main.
Four layers, in order of impact:
- Add a
.cursor/rulesfile. The Cursor project rules documentation states that rule contents are included at the start of the model context for any session inside the project. Write rules in plain language: "never inline API keys, tokens, or secrets in client code; always assume any string ending up in a.tsxfile underapp/orscreens/ships in the bundle; route external API calls through/api/proxyon the backend." - Add a
.cursorignoreand a.codeiumignorelisting.env*,.env.local,*.pem,secrets/**. This stops the agent from reading the live secret values when it scans the project for context. - Wire a pre-commit hook to a secret scanner (gitleaks, trufflehog, or
git-secrets). A literal that matches a known provider prefix never reaches a remote branch. - Add a CI step that runs the same scanner against the built bundle (the file under
dist/or the.aabfrom EAS Build). This catches the case where Metro inlined a value that does not appear in any tracked source file.
Layer one shapes what Cursor generates. Layers two through four catch the residual cases the rule did not stop.
How does each storage pattern compare on real exposure?
| Pattern | Where the key lives | In the shipped bundle? | Recovery time for an attacker | Use case |
|---|---|---|---|---|
Hardcoded in .tsx | Plain string in source | Yes, plain text | Seconds (grep) | Never |
process.env.EXPO_PUBLIC_KEY | Inlined by Metro | Yes, plain text | Seconds (grep) | Public config only |
react-native-config value | Inlined via BuildConfig | Yes, plain text in binary | Minutes (apktool, strings) | Non-secret config |
| Native keychain (Keychain, Keystore) | Encrypted, OS scoped | No (written at first run) | Hours (rooted device) | User-bound tokens |
| Backend proxy with session auth | Server only | No | Not on the client | Provider secrets |
| Short-lived signed URL or token | Generated per request | Token only, expires | Bounded by TTL | Single-use access |
The last two rows are the only ones that hold for a production app that uses a real provider secret. The first three are the rows Cursor will reach for by default.
What to watch out for
A recurring myth is that minification or Hermes bytecode hides the value. It does not. Hermes still ships the string literal, and any general-purpose binary tool (strings, Hopper, Ghidra) recovers it. Treat minification as a build optimization, not a security control.
A related myth is that App Transport Security or certificate pinning protects the key. They protect the channel, not the artifact. A user with the binary on their device reads the value before any network call happens.
A third myth is that rotating the key is a substitute for fixing the pattern. Rotation closes the window on the leaked value; the next build with the same code structure leaks the next value. Pattern first, rotation second.
For teams that want an external automated read of a compiled build before submission, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission scanning aligned with OWASP MASVS for no-code and vibe-coded apps. The scanner looks at the artifact the store will see, which is the same surface a determined reader sees.
Key takeaways
- Cursor inlines the key it has access to; the production bundle then carries the literal because Metro replaces
process.env.EXPO_PUBLIC_*with the value at build time. - The fix is structural: a backend proxy holds the provider secret, the client uses client-safe keys (Stripe
pk_live_, Supabaseanonwith RLS) for the calls that genuinely belong on the device. - Set
.cursor/rules,.cursorignore, and a secret-scanner pre-commit hook before the next Cursor session, then add a CI scan of the built bundle to catch anything Metro inlined silently. - Decode any build already on TestFlight or in Google Play internal testing and grep the JS bundle for provider prefixes; rotate the second a real secret turns up.
- Some teams outsource the pre-submission scan to platforms like PTKD.com (https://ptkd.com) so the report is generated against the artifact the store will see, not against the local source tree.




