DEV Community

Russel Dsouza
Russel Dsouza

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

Check what's set:

eas env:list --environment production
Enter fullscreen mode Exit fullscreen mode

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:pull skips them, and resolving app.config.js locally 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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 --environment explicitly, 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"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 plaintext for EXPO_PUBLIC_*, sensitive for build values you resolve locally, and secret for build-only tokens
  • [ ] Set environment on every build profile in eas.json
  • [ ] Make sure .env* files are git-ignored
  • [ ] Wrap eas update in 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.

Top comments (0)