The one rule
Anything your JavaScript can read at runtime, an attacker can read too. An app bundle is a zip file. Unzip an .ipa, find the JS bundle, run strings on it, and every EXPO_PUBLIC_ value is right there in plain text — because Metro inlines them as literals at build time. There is no obfuscation step that changes this and no secure store that helps, because the value has to be in the binary for the app to use it offline.
# Prove it to yourself in 30 seconds: unzip -o build.ipa -d /tmp/app strings /tmp/app/Payload/*.app/main.jsbundle | grep -i "EXPO_PUBLIC\|https://" # Every publishable value you shipped, listed.
This isn’t an Expo weakness — it’s true of every mobile framework and every web frontend. The useful consequence is that the decision is binary and you can make it per value in about two seconds: would I be fine printing this on a billboard? If yes, bundle it. If no, it goes on a server.
Classify every value before you write any config
| Value | In the bundle? | Where it goes |
|---|---|---|
| Supabase project URL | ✅ Safe to bundle | EXPO_PUBLIC_SUPABASE_URL |
| Supabase anon / publishable key | ✅ Safe to bundle | EXPO_PUBLIC_SUPABASE_ANON_KEY — RLS is the real guard |
| Supabase service_role key | ❌ Never | Server / Edge Function only |
| Stripe publishable key (pk_) | ✅ Safe to bundle | EXPO_PUBLIC_STRIPE_PK |
| Stripe secret key (sk_) | ❌ Never | Your backend, which returns a client secret |
| OpenAI / Anthropic API key | ❌ Never | Backend proxy route |
| Google Maps mobile API key | ⚠️ Bundled by design | app.json — restrict by bundle ID + SHA-1 |
| Sentry DSN | ✅ Safe to bundle | Public by design |
| Sentry auth token (source maps) | ❌ Never | EAS secret, build-time only |
| Your own API base URL | ✅ Safe to bundle | EXPO_PUBLIC_API_URL |
The Supabase anon key confuses people because it looks like a credential. It is one — a deliberately low-privilege one, designed to be public. What protects your data is row-level security on the tables, not the secrecy of that key. If your RLS policies are wrong, hiding the anon key wouldn’t have saved you; if they’re right, publishing it costs nothing.
The setup: files, prefixes, precedence
# .env.example — COMMIT this one EXPO_PUBLIC_API_URL=https://api.example.com EXPO_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJ... # .env.local — gitignored, your machine # .env.production — gitignored, real values # .gitignore .env .env.local .env.*.local .env.production
Expo loads these in a fixed order and a real shell variable always wins: process.env from your shell or CI, then .env.local, then .env.[mode].local, then .env.[mode], then .env. When a value seems stale, that precedence chain plus Metro’s cache is nearly always the reason.
// lib/config.ts — read once, validate loudly, never sprinkle process.env
function required(name: string, value: string | undefined) {
if (!value) throw new Error(`Missing ${name}. Check your .env file.`);
return value;
}
export const config = {
apiUrl: required('EXPO_PUBLIC_API_URL', process.env.EXPO_PUBLIC_API_URL),
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL', process.env.EXPO_PUBLIC_SUPABASE_URL),
supabaseAnonKey: required(
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
),
} as const;One thing that trips everyone: you cannot destructure or index process.env. Metro does a literal text substitution on process.env.EXPO_PUBLIC_FOO, so const { EXPO_PUBLIC_FOO } = process.env and process.env[key] both produce undefined at runtime. Write the full property access every time.
Per-environment builds with eas.json
// eas.json
{
"build": {
"development": {
"developmentClient": true,
"env": { "EXPO_PUBLIC_API_URL": "http://192.168.1.20:3000" }
},
"preview": {
"distribution": "internal",
"env": { "EXPO_PUBLIC_API_URL": "https://staging.example.com" }
},
"production": {
"env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
}
}
}
# Build-time-only values that must not reach the bundle:
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value sntrys_...
eas secret:listEAS secrets solve “keep it out of git”, not “keep it out of the app.” A Sentry source-map upload token is the perfect case — the build worker needs it, the phone never does. If you find yourself wanting an EAS secret whose value is then read by app code, you have a server-side problem wearing a config disguise.
The pattern for real secrets
Say your app calls an LLM. The key cannot be in the app, so the app calls something you control, and that thing holds the key. A Supabase Edge Function is about fifteen lines:
// supabase/functions/ask/index.ts — runs on the server
Deno.serve(async (req) => {
// 1. Verify the caller. Without this you built a free public proxy.
const jwt = req.headers.get('Authorization');
const { data: { user } } = await supabase.auth.getUser(jwt);
if (!user) return new Response('Unauthorized', { status: 401 });
// 2. Rate limit per user — a leaked endpoint is a leaked bill.
if (await overQuota(user.id)) return new Response('Quota exceeded', { status: 429 });
// 3. The secret only ever exists here.
const r = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: await req.text(),
});
return new Response(r.body, { headers: { 'content-type': 'application/json' } });
});Steps 1 and 2 are the ones people skip, and they matter more than the key hiding. An unauthenticated proxy endpoint is functionally the same as publishing the key, except the bill arrives faster because someone found it with a scanner.
Quick debugging table
- Value is undefined: missing
EXPO_PUBLIC_prefix, or you destructuredprocess.env. - Value is stale after editing .env:
npx expo start --clear. Inlined literals are cached in the Metro transform cache. - Works locally, undefined in an EAS build: your
.envis gitignored (correctly) and never reached the build worker. Put it in the profile’senvblock or an EAS secret. - Need it in app.json: rename to
app.config.jsand return a JS object — static JSON can’t read env vars.
Start from a project that already draws the line
The most expensive version of this mistake is finding a service-role key in a shipped bundle after launch. Apps generated by ShipNative come with the split already made — publishable values in EXPO_PUBLIC_ config, privileged operations behind server-side policies, and a .env.example that documents what a new machine needs. You export the whole Expo project, so nothing here is hidden from you.