Research · October 2026
Your AI API key ships inside your Expo app
The AI feature is the newest part of many React Native and Expo apps, and the easiest place to leak a key. We scanned 869 open-source apps: 12 compile an OpenAI, Gemini, Anthropic or other AI provider key into the app they ship, and every one of the 12 calls the provider straight from the phone.
EXPO_PUBLIC_ variableWhy .gitignore does not save you
Every one of these apps did the thing tutorials tell you to do: the key lives in .env, and .env is not committed. The code reads it like this:
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
headers: { Authorization: `Bearer ${process.env.EXPO_PUBLIC_GROQ_API_KEY}` },
...
});
Expo replaces process.env.EXPO_PUBLIC_* with the value at build time. The key is not in your repository, but it is a plain string in the JavaScript bundle of every build you ship. Expo's own documentation says not to put secrets in these variables. react-native-config and react-native-dotenv work the same way: they exist to put values into the app.
sk- or AIza. No reverse engineering skills. Automated scrapers do this at scale, and AI keys are what they look for, because a working key turns straight into free compute on your bill.What we found
| Provider | Apps |
|---|---|
| Google Gemini | 6 |
| OpenAI | 5 |
| Anthropic | 2 |
| Groq | 2 |
| OpenRouter, xAI, Replicate, fal.ai | 1 each |
Several apps ship more than one key. Eight use EXPO_PUBLIC_ variables, three react-native-dotenv, one react-native-config. One of the 12 is published on the app stores. They are mostly recent: chat assistants, journaling with AI summaries, photo and voice features added in the last year.
While checking these results we also found, in a different app, a server credential committed to the repository itself. We told the owner without publishing any details, and we name no app here.
The fix
The key has to live somewhere the user's phone cannot read:
- Move the call behind your own endpoint. An Expo API route (
app/api/chat+api.ts), a Firebase or Supabase function, or any small server. It holds the key, the app calls it. - Check who is calling. Require your user's session token, and add per-user rate limits. Otherwise your endpoint becomes the free proxy instead of the key.
- Revoke the key that shipped. Old builds stay on phones and in APK mirrors. Moving the code does not un-leak the old key.
- Set a spending limit at the provider, so a leak costs you a bounded amount.
If users bring their own key, store it with expo-secure-store or the Keychain/Keystore, and do not fall back to a built-in key when theirs is missing. We saw that fallback too.
Find it in your app
Since version 0.1.47, NativeKeel reports this as a high finding. It reads your code, not your secrets: the variable name is enough to know the value is compiled in. Server folders and Expo API routes are left out, because code there never reaches the phone.
▲ HIGH Groq API key ships inside the app (utils/ai/client.ts:3)
The app reads EXPO_PUBLIC_GROQ_API_KEY, and EXPO_PUBLIC_ variables are compiled
into the JavaScript bundle; the app also calls Groq directly. ...
Run it on your app
One command, no signup, nothing leaves your machine:
npx nativekeel
See a sample reportWhat else it checks