Your EXPO_PUBLIC_ Keys Are Public: A Practical Guide to EAS Environment Variables
Open almost any Expo repo and you'll find a .env file with a mix of things: an API URL, a Supabase key, a Sentry DSN, maybe a Stripe secret key someone added "just for testing." Some of those are safe to ship inside you
Open almost any Expo repo and you'll find a .env file with a mix of things: an API URL, a Supabase key, a Sentry DSN, maybe a Stripe secret key someone added "just for testing."
Some of those are safe to ship inside your app. Some are not. And the EXPO_PUBLIC_ prefix doesn't make anything private. It does the opposite.
This guide sorts it out: which values can go where, how EAS environments and visibility levels work, and one easy-to-miss mistake with OTA updates.
The one rule: anything in the app is public
Every EXPO_PUBLIC_ variable is inlined into your JavaScript bundle at build time. Anyone who downloads your app can extract it. The Expo docs say it plainly: treat client-side code as public, and secret visibility in EAS "does not provide any additional security for values that you end up embedding in your application itself."
So the first question for every variable isn't "how do I hide it?" It's "does the app actually need this?"
Sort every value into one of three buckets
| Bucket | Examples | Where it lives |
|---|---|---|
| Public, used at runtime | API base URL, Supabase URL and publishable key, Sentry DSN, PostHog project key | EXPO_PUBLIC_* |
| Build-time only |
APP_VARIANT, bundle ID suffix, GOOGLE_SERVICES_JSON file, Sentry auth token for source maps |
Non-prefixed EAS variable, read in app.config.js or by build tools |
| Server only | Stripe secret key, Supabase secret/service key, OpenAI key, webhook secrets | Your backend or edge functions. Never the app. |
If a key can spend money, read every user's data, or call a paid API, it belongs in the third bucket. The app calls your server, and your server holds the key.
A quick test: if this value leaked on Twitter tomorrow, would you need to rotate it? If yes, it can't be EXPO_PUBLIC_.
How EAS environments work
EAS gives each project three environments by default: development, preview, and production. Each is an independent set of variables, and a variable can belong to one environment or several.
Create variables with eas env:set (it creates or updates):
eas env:set --name EXPO_PUBLIC_API_URL \
--value https://api.staging.example.com \
--environment preview --visibility plaintext
eas env:set --name EXPO_PUBLIC_API_URL \
--value https://api.example.com \
--environment production --visibility plaintext
Check what's set:
eas env:list --environment production
Pick the right visibility
| Visibility | Who can read it | Use for |
|---|---|---|
| Plain text | Dashboard, CLI, build logs |
EXPO_PUBLIC_* values (they're public anyway) |
| Sensitive | CLI and dashboard (toggle); hidden in logs | Build-time values you need to resolve locally |
| Secret | Only EAS servers; hidden everywhere else | Tokens only the build needs, like a source-map upload token |
Two details catch people out:
-
Secret variables can't be pulled.
eas env:pullskips them, and resolvingapp.config.jslocally needs at least sensitive visibility. If your config reads a variable on your laptop, don't make it secret. -
Secret doesn't make an
EXPO_PUBLIC_value private. It hides it in the dashboard, but the value still ships in the bundle.
Connect build profiles to environments
In eas.json, each build profile picks its environment:
{
"build": {
"development": {
"developmentClient": true,
"environment": "development"
},
"preview": {
"distribution": "internal",
"environment": "preview"
},
"production": {
"environment": "production"
}
}
}
Now eas build --profile preview gets the staging API URL, and --profile production gets the real one, with no .env juggling.
Use build-time variables in app.config.js
Non-prefixed variables are available in app.config.js, which is perfect for running preview and production builds side by side on one phone:
// app.config.js
const variant = process.env.APP_VARIANT ?? 'development';
const suffix = variant === 'production' ? '' : `.${variant}`;
export default {
name: variant === 'production' ? 'Acme' : `Acme (${variant})`,
ios: { bundleIdentifier: `com.acme.app${suffix}` },
android: {
package: `com.acme.app${suffix}`,
googleServicesFile:
process.env.GOOGLE_SERVICES_JSON ?? './google-services.json',
},
};
Set APP_VARIANT per environment in EAS. Upload google-services.json as a file variable, and the fallback keeps local builds working.
Pull variables for local development
eas env:pull --environment development
This writes the readable variables to a .env file (like .env.local). Add it to .gitignore. The dashboard and EAS become the source of truth, and nobody pastes keys into Slack again.
The OTA mistake: updates read the environment too
Here's the one that bites teams in production.
EXPO_PUBLIC_ values are baked into the JavaScript bundle. An OTA update ships a new bundle, so it gets whatever environment you publish it with:
eas update --channel production --environment production
Publish a production update with the wrong environment, and every production user's app now points at your staging API. The native build was fine; the update replaced the config.
Guard against it:
- Always pass
--environmentexplicitly, and match it to the channel. - Put the command in a package script or an EAS Workflow so nobody types it by hand:
{
"scripts": {
"update:preview": "eas update --channel preview --environment preview",
"update:prod": "eas update --channel production --environment production"
}
}
- Log the API base URL on startup in preview builds, so a mismatch shows up in QA, not in support tickets.
A 10-minute audit for your project
- [ ] Search the codebase for
EXPO_PUBLIC_and check each one against the three buckets - [ ] Move any secret key out of the app and behind your backend, then rotate it (it has already shipped)
- [ ] Use
plaintextforEXPO_PUBLIC_*,sensitivefor build values you resolve locally, andsecretfor build-only tokens - [ ] Set
environmenton every build profile ineas.json - [ ] Make sure
.env*files are git-ignored - [ ] Wrap
eas updatein scripts that always pass--environment
Wrapping up
Environment variables feel like plumbing until one leaks or an update points production at staging. The fix is mostly sorting: public runtime config in EXPO_PUBLIC_, build config in EAS variables, and real secrets on your server where users can't reach them.
If you'd rather start from a project where this is already laid out, RapidNative generates real Expo apps you can export, with config you can drop straight into this setup.
How does your team split config between the app and the server? Share your setup in the comments.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.