The .env file is in .gitignore, and the repository holds no keys. As an indie developer, that's usually where I stop and tell myself it's fine. But what reaches your users isn't your source. It's the bundle.
So I made an empty Expo project, planted dummy keys on purpose, ran expo export, and went looking through the finished file by hand. The only thing I changed was where I checked: the bundle instead of the source.
What I'd like to say up front is this: check for keys in the exported bundle, not in the source. Below is the procedure, along with what actually happened when I ran it.
What prompted this was a write-up on Zenn (in Japanese) from a developer who reworked how their app calls an image-generation API before release. Hiding the key wasn't enough on its own, and the question "what if someone skips the app and calls the endpoint directly?" applies to my own apps too.
The setup
I used a throwaway project. Every value is a dummy; no real key was involved.
| Item | Detail |
|---|---|
| Date | 2026-10-13 |
| Project | create-expo-app blank template (expo 57.0.28, react-native 0.86.3) |
| Export | npx expo export --platform android (emits a Hermes bytecode .hbc file) |
| Node | 22 |
I put three lines in .env:
EXPO_PUBLIC_API_BASE=https://api.example.com
EXPO_PUBLIC_OPENAI_KEY=sk-proj-DUMMY0123456789abcdefABCDEF0123456789
SERVER_ONLY_TOKEN=whsec_DUMMYSERVERONLY0123456789App.js reads process.env.EXPO_PUBLIC_API_BASE and process.env.EXPO_PUBLIC_OPENAI_KEY, contains a Google-API-key-looking string typed directly into the source, and reads process.env.SERVER_ONLY_TOKEN. In other words, three things you shouldn't do, side by side.
Step 1: Export something close to what ships
npx expo export --platform android --output-dir distThe output ends with dist/_expo/static/js/android/index-….hbc. That file is the JavaScript that goes inside the app. Expo's environment-variable docs say that EXPO_PUBLIC_ variables "will be visible in plain-text in your compiled application." Let's go and see that for ourselves.
Step 2: Start with grep (two traps)
grep -a -o -E 'sk-[A-Za-z0-9_-]{20,}' dist/_expo/static/js/android/*.hbcOn my machine this printed one line:
sk-proj-DUMMY0123456789abcdefABCDEF0123456789InvalidTwo things stood out.
First, that trailing Invalid. A neighboring string is glued onto the end of the key. The string table in the bytecode has no separators, so the regex keeps extending as long as the characters fit. Don't treat a match as the exact boundary of the key.
Second, the number you get from grep -c. Running grep -c -a 'sk-proj' on the same file returned 1, but that's the number of matching lines, not occurrences. The binary is effectively one line, so you get 1 however many times the string appears. If you want a count, use -o and count the output.
Step 3: A script that searches by shape and by value
Typing out key formats from memory every time gets old, so I combined two checks into one script:
- By shape: pick up strings that look like well-known key formats.
- By value: check whether any value from
.envappears verbatim in the bundle.
// scan-bundle.mjs — flags secret-looking strings and raw .env values left in an exported Expo bundle
// usage: node scan-bundle.mjs <export-dir> [.env path] [--allow=NAME,NAME]
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
const args = process.argv.slice(2);
const allowArg = args.find((a) => a.startsWith('--allow='));
const allow = new Set((allowArg ?? '').replace('--allow=', '').split(',').filter(Boolean));
const [dir, envPath = '.env'] = args.filter((a) => !a.startsWith('--'));
if (!dir || !existsSync(dir)) {
console.error('usage: node scan-bundle.mjs <export-dir> [.env]');
process.exit(2);
}
// 1) By shape: common key formats (false positives are fine; a human decides)
const SHAPES = [
['OpenAI-style', /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g],
['Google API key', /AIza[0-9A-Za-z_-]{35}/g],
['Stripe secret', /sk_(?:live|test)_[0-9A-Za-z]{16,}/g],
['Webhook secret', /whsec_[0-9A-Za-z]{16,}/g],
['AWS access key', /AKIA[0-9A-Z]{16}/g],
['JWT-like string', /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
];
// 2) By value: does any .env value appear verbatim in the bundle?
const envValues = existsSync(envPath)
? readFileSync(envPath, 'utf8').split('\n')
.map((l) => l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/))
.filter(Boolean)
.map(([, k, v]) => [k, v.replace(/^["']|["']$/g, '')])
.filter(([k, v]) => v.length >= 8 && !allow.has(k))
: [];
const mask = (s) => (s.length <= 10 ? '***' : s.slice(0, 6) + '…' + s.slice(-2));
function* walk(d) {
for (const n of readdirSync(d)) {
const p = join(d, n);
statSync(p).isDirectory() ? yield* walk(p) : yield p;
}
}
let hits = 0;
for (const f of walk(dir)) {
if (!['.hbc', '.js', '.map', '.bundle'].includes(extname(f))) continue;
const text = readFileSync(f).toString('latin1'); // Hermes bytecode still stores string literals as plain text
for (const [label, re] of SHAPES) {
for (const m of text.matchAll(re)) {
hits++;
console.log(`SHAPE ${label.padEnd(16)} ${mask(m[0])} @${m.index} ${f}`);
}
}
for (const [k, v] of envValues) {
const i = text.indexOf(v);
if (i >= 0) {
hits++;
console.log(`VALUE ${k.padEnd(28)} ${mask(v)} @${i} ${f}`);
}
}
}
console.log(hits ? `\n${hits} hit(s). Investigate any VALUE you did not allow on purpose.` : '\n0 hits');
process.exit(hits ? 1 : 0);It exits with 1 if anything is found and 0 if not, so it can sit right after the build as a gate.
Results: what was found and what wasn't
I ran it against the first build, the one with all three bad habits:
node scan-bundle.mjs dist .envSHAPE OpenAI-style sk-pro…89 @131303 …/index-….hbc
SHAPE Google API key AIzaSy…qr @140062 …/index-….hbc
VALUE EXPO_PUBLIC_API_BASE https:…om @150635 …/index-….hbc
VALUE EXPO_PUBLIC_OPENAI_KEY sk-pro…89 @131303 …/index-….hbc
4 hit(s). Investigate any VALUE you did not allow on purpose.| How it was placed | Value in the bundle? | How it was found |
|---|---|---|
EXPO_PUBLIC_OPENAI_KEY (read in code) | Yes | Shape and value |
| Google-key-looking string typed into the source | Yes | Shape only (it isn't in .env, so a value search can't see it) |
EXPO_PUBLIC_API_BASE (a URL) | Yes | Value (public by nature, so it belongs on the allow list) |
SERVER_ONLY_TOKEN (no EXPO_PUBLIC_ prefix) | No | — (only the variable's name survived; the value was not found) |
Two results differed from what I'd have guessed before running it.
An EXPO_PUBLIC_ variable you don't use is not embedded, even if it's in .env. For the second build I removed the code that read the key, left .env untouched, and exported again. The key's value was gone from the bundle. Only variables you actually reference as process.env.EXPO_PUBLIC_… in code get inlined. So the better question isn't "what's in my .env?" but "which variables does my code read?"
A variable without the EXPO_PUBLIC_ prefix is empty inside the app. process.env.SERVER_ONLY_TOKEN has no value at runtime, and only its name stayed in the bundle. That's good news for leakage and bad news for usability: a value that only the server may hold has to live on the server.
Re-scanning after the fix
I moved the call that needs the key to the server. The app now sends only a short kind of request (for example icon):
import { Text, View } from 'react-native';
const API_BASE = process.env.EXPO_PUBLIC_API_BASE;
async function ask(kind) {
// The key lives on the server. The app only sends a choice from a fixed list.
const res = await fetch(`${API_BASE}/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ kind }),
});
return res.json();
}
export default function App() {
return (
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text onPress={() => ask('icon')}>{API_BASE}</Text>
</View>
);
}Export again and scan, allowing only the URL that is meant to be public:
rm -rf dist && npx expo export --platform android --output-dir dist
node scan-bundle.mjs dist .env --allow=EXPO_PUBLIC_API_BASE
# → 0 hits (exit code 0)Drop the allow list and exactly one hit, EXPO_PUBLIC_API_BASE, comes back. Naming the values that are allowed to appear makes it easy to notice when someone later adds a variable that isn't.
The design of keeping keys off the client has its own articles: a thin relay on Workers for the proxy, and the secret boundary and rotation runbook for swapping keys. This article is the other side: checking that the design actually held in a real build.
Limits, and what I did not check
- Shape matching misses keys in unusual formats. The value check (comparing against
.env) fills part of that gap, but it can't help with values that aren't in.env. - I only exported for Android. I haven't checked an iOS export, or the final
.aab/.ipathat EAS Build produces. - If you publish source maps, include the
.mapfiles in the same scan. The script walks.mapfiles too. - "No key-shaped string in one file" doesn't prove the app is safe. A scan reduces what slips through; it doesn't replace a design where the key was never on the client.
A key isn't something to hide in the app; it's something that never goes into the app. The scan only confirms that the line is holding.
Before your next release build, run expo export once and just look at this script's exit code. I plan to run the same one-liner on the next update of one of my own apps.