Where NOT to Store API Keys in a React Native App (and Where You Should)
If you've ever written EXPO_PUBLIC_API_KEY or imported a .env file straight into a React Native component, you probably have a React Native security problem without realizing it. The reason is simple: anything compiled into your JavaScript bundle ships to the user's device, and that device is not a trusted environment.
This isn't theoretical. Anyone with your APK or IPA can unzip it and read the bundle as plain text. Let's go through exactly where developers get React Native API keys wrong, and what actually works instead.
The JavaScript bundle is not a vault
Metro and Hermes compile your code — they don't encrypt it. A .env loaded via react-native-dotenv or react-native-config gets substituted at build time with its literal value, which ends up embedded in the final bundle. It doesn't matter that your backend never sees the .env file — the value is already sitting inside the .apk you upload to Google Play.
Places you should never put an API key
1. Environment variables baked into the client
EXPO_PUBLIC_*, REACT_APP_*, or anything injected by react-native-config ends up in plain text inside the JS bundle. These are fine for public config (a feature flag, an API base URL) — never for secrets.
2. Hardcoded in source
const STRIPE_SECRET_KEY = "sk_live_51H...";
This is the first thing an AI coding assistant suggests when you ask it to "connect this to Stripe," and the first thing an attacker searches for with grep.
3. AsyncStorage or unencrypted filesystem storage
AsyncStorage persists data in SQLite (Android) or a plist (iOS) without real encryption. On a rooted or jailbroken device, it's readable directly.
4. Info.plist, strings.xml, or google-services.json
Developers often add third-party keys here because it "feels like native config." It's still the same binary distributed publicly.
5. Your git history
Even if you delete a key from the current file, it lives on in previous commits. git log -p | grep sk_ finds it in seconds.
Check it yourself
On a release build you've already generated:
# Android
unzip app-release.apk -d app-release
strings app-release/assets/index.android.bundle | grep -Ei "sk_live|AIza|api[_-]?key"
# iOS
strings MyApp.app/MyApp | grep -Ei "sk_live|AIza"
If anything shows up, that key is already compromised the moment you publish the app. Rotating it prevents further damage, but it doesn't erase the copies already downloaded to devices.
Where API keys should actually live
Public keys, client-side — that's fine
A Stripe publishable key, Firebase config, or an analytics ID are designed to be public. The client is exactly the right place for these.
Session tokens, in the Keychain/Keystore
For tokens issued after login (short-lived JWTs, refresh tokens), use react-native-keychain or the native Keystore. It's OS-level encryption built for precisely this.
Third-party secret keys: never on the phone
An OpenAI API key, a database password, or a Stripe secret key should never reach the device at all. If your app needs that service, your backend makes the call — not the client.
The right pattern: backend + secrets manager
Your React Native app calls your API; your API calls OpenAI, Stripe, or your database. The phone never sees those keys. What's left to solve is: where does your backend — and your CI/CD, and the AI assistant writing that backend code — store those keys without them ending up in a committed .env?
That's where Koove fits. Instead of pasting the key into a file, you store it once:
koove set OPENAI_API_KEY sk-proj-xxxx --env prod
The value is encrypted client-side with envelope encryption (X25519 + AES-256-GCM) before it ever leaves your machine; Koove's server only ever stores ciphertext, never the key in the clear. Your backend code drops the embedded secret and just references it by name:
import { koove } from "@koove/sdk";
const apiKey = await koove.get("OPENAI_API_KEY");
const client = new OpenAI({ apiKey });
The key is only decrypted for verified consumers — an authorized backend, or, when the use case genuinely requires it, a mobile consumer validated with App Attest / Play Integrity plus biometrics. Setup details live in /docs/sdk.
Being honest about the limits
Worth being direct here rather than salesy. Koove protects a secret up to the point of download and decryption — it can't make a secret already delivered to a compromised device disappear, and it doesn't make your app unhackable. What it does is dramatically shrink the exposure surface: the secret doesn't live in your repo, doesn't live in the bundle, and doesn't sit in plain text on any server — it only reaches a client that can prove it's legitimate. If your use case genuinely requires the secret to reach the mobile device itself (uncommon, but it happens), attestation raises the bar significantly against emulators or tampered app builds — but once it's decrypted in that device's memory, that device has it.
In the era of AI-generated code
When you ask Copilot or Cursor to "add Stripe integration," the most likely suggestion is a hardcoded key directly in the component — it's the pattern most represented in its training data. AI assistants can use the same CLI (koove set NAME value) to store the secret instead, so your generated code only ever references the name. We go deeper into this specific failure mode in /ai-security.
Quick checklist
- Any
sk_,AIza,Bearer, or password in your bundle? Check withstringsbefore every release. - Do third-party keys ever pass through the client? They should only pass through your backend.
- Are session tokens sitting in AsyncStorage? Move them to the Keychain/Keystore.
- Is your
.envin.gitignorenow but was committed before? Checkgit log -p. - Does your AI assistant have free rein to generate code with plaintext secrets? Give it a safe way to store them instead.
If you'd rather stop deciding case-by-case where each key belongs and centralize this with end-to-end encryption, check out the plans at /plans or create your account at /register. For specific questions about how it fits your stack, /faq covers the most common cases.