An Expo app reads its settings from two places: a .env file on your machine, and environment variables stored on EAS for cloud builds and updates. The name decides where a value ends up. A name with the EXPO_PUBLIC_ prefix is written into the JavaScript bundle, so every user can read it. Any other name stays on your build machine or on your server. This guide answers the questions people search for, starting with the rule that matters most: a secret never goes in an EXPO_PUBLIC_ variable.
EXPO_PUBLIC_ values are public
The prefix is the whole rule. When Expo bundles your app, it replaces each process.env.EXPO_PUBLIC_NAME in your code with the value from your .env files. That value then ships inside the JavaScript that every device downloads, so anyone with the app can read it. Expo's guide says not to store private keys there. Keep only public values in .env:
# .env (gitignored)
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyRead them with a static dot access
Expo inlines a variable only when your code names it as a property of process.env with dot notation. Anything more dynamic is left alone, and the code gets no value:
// Inlined: a static dot access
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
// Not inlined: a computed key, or destructuring
const name = "EXPO_PUBLIC_API_URL";
const missing = process.env[name];
const { EXPO_PUBLIC_API_URL } = process.env;ReadyNative's client env file follows the same rule. It spells out every key, so the build can inline each one, and it never looks one up by name.
Which .env file wins
Expo picks files by mode. A development run, such as npx expo start, loads these files, and the first one that sets a name wins:
- .env.development.local
- .env.local
- .env.development
- .env
A production run, such as npx expo export, loads the same list with the .production names. Put values that differ per machine, such as your LAN IP, in .env.local, and add .env*.local to .gitignore. On SDK 58 and later, Expo ignores other names such as .env.staging, so stick to these four.
EAS environment variables replace eas secret
Cloud builds and updates never see your local files. Store the values on EAS instead, one variable for each environment. The older commands are deprecated: eas secret:create tells you to use eas env:create, and env:create tells you to use env:set. Use env:set:
bunx eas-cli env:set --environment production --name EXPO_PUBLIC_API_URL --value https://api.example.com --visibility plaintext
bunx eas-cli env:set --environment production --name CLERK_WEBHOOK_SIGNING_SECRET --value whsec_xxx --visibility sensitive
bunx eas-cli env:list --environment productionenv:set creates a variable or updates it. Each one takes a visibility, and the table below shows what each setting allows.
| Visibility | On expo.dev and in EAS CLI | In build and workflow logs | Used by eas update and env:pull |
|---|---|---|---|
| plaintext | Visible | Visible | Yes |
| sensitive | Behind a toggle on expo.dev; readable in EAS CLI | Masked | Yes |
| secret | Not readable | Masked | No |
Choose the lowest visibility that works. An EXPO_PUBLIC_ value is public whatever its visibility, so plaintext is right for it. EAS Hosting cannot deploy secret variables, so server keys you deploy there need sensitive. Use secret for a value only a build job needs, such as an NPM_TOKEN.
Each environment is its own set of values. A build uses the environment named in its eas.json profile. Without that field, a store build uses production, a development client build uses development, and anything else uses preview.
bunx eas-cli env:pull --environment development # writes .env.localenv:pull writes the values that its visibility allows to .env.local by default. Expo loads .env.local above .env, so a pulled value wins over the one in .env. Keep that file out of git.
Which values are safe in the client
Ask one question about each value: if someone had it, could they read private data, spend money or act as your account? If yes, it has no prefix and stays on the server. If no, it can carry the prefix.
Safe for the client, so EXPO_PUBLIC_:
- The base URL of your API.
- The Supabase project URL and the anon key, as Supabase auth in Expo sets them up. Supabase says its publishable key is safe to expose in a mobile app, and it calls anon the legacy form of that key and plans to retire it by the end of 2026. Plan to move to the publishable key.
- The Clerk publishable key, the RevenueCat public SDK keys and the Stripe publishable key.
Server only, so no prefix:
- CLERK_SECRET_KEY and CLERK_WEBHOOK_SIGNING_SECRET.
- STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
- REVENUECAT_SECRET_KEY.
- A Supabase secret key (sb_secret_…), or the legacy service_role key. Supabase says it bypasses Row Level Security, so it must never reach the app.
Server keys: read them in an API route
Server keys have no prefix, so they never reach the bundle. In a Pro tree set up with the api-routes backend, src/server/env.ts reads each one from process.env when a request arrives. The Clerk webhook route uses it like this:
// src/app/api/webhooks/clerk+api.ts
const secret = serverEnv().CLERK_WEBHOOK_SIGNING_SECRET;
if (!secret) return error("Not found", 404);Without the secret, the route answers 404. On EAS Hosting, set the server keys with sensitive visibility, as shown above.
app.config.ts reads them at build time
app.config.ts runs before the app is built, on your machine or on the build worker. It can read any name with process.env, prefixed or not. The template uses APP_VARIANT this way to pick the app name and bundle id, and sets it in each eas.json profile:
const VARIANT = (process.env.APP_VARIANT ?? "dev") as Variant;Plaintext and sensitive values are visible to the config when EAS CLI resolves it on your machine. Secret values are not, so any value the config needs must be at least sensitive.
Environment variables and EAS Update
On SDK 55 and later, eas update requires the --environment flag. When you pass it, the project's .env files are ignored, and the update uses that environment's values:
bunx eas-cli update --environment productionExpo inlines the EXPO_PUBLIC_ values of that environment into the bundle it publishes. So a value you change on EAS reaches users only with a new update. Secret values are not available to the update at all.
Common mistakes
- A secret under EXPO_PUBLIC_. The bundle ships it to every device. Rename it without the prefix and read it in an API route.
- A computed name. process.env[name] and destructuring are not inlined. Write out each process.env.EXPO_PUBLIC_NAME in full.
- Undefined after you add a key. Reload first. If it still reads undefined, restart the dev server with the cache cleared: bun run start -- -c, or npx expo start --clear.
- A key only in .env. That file is gitignored and never uploaded, so EAS builds and eas update cannot see it. Set the key on EAS for each environment you build.
- The wrong environment. A production build reads only the production values. A key set only for preview is undefined in it.
- Secret for a value the config or an update needs. Local config resolution and eas update cannot read secret values. Use sensitive instead.
Read next: Expo push notifications, which walks through the EAS project id and the push credentials, then the environment variables page for the full key list.
FAQ
What replaced eas secret?
The eas secret commands are deprecated. Use eas env:set, which saves a variable for one or more environments with visibility plaintext, sensitive or secret. Use secret only for values that a build job needs.
Are EXPO_PUBLIC_ variables secret?
No. Expo writes each EXPO_PUBLIC_ value into the JavaScript bundle, so anyone with the app can read it. Put only public values such as URLs and publishable keys there.
Why is EXPO_PUBLIC_API_URL undefined?
Check that the code reads it with a static dot access, that the key is set for the environment your build uses, and that the dev server restarted with the cache cleared (bun run start -- -c).
Is CLERK_WEBHOOK_SIGNING_SECRET safe in the app?
No. It has no EXPO_PUBLIC_ prefix, so it stays out of the bundle. The template's Clerk webhook API route reads it on the server, and EAS Hosting needs it set with sensitive visibility.
More guides
- Unistyles 3 with Expo: a working setup
- TanStack Query in Expo: offline and refetch
- Expo zustand: persist a store across restarts
- jest-expo setup, Expo Router tests and fixes
- Expo auth: Clerk vs Supabase vs Better Auth
- AsyncStorage vs MMKV vs expo-sqlite/kv-store
- Expo i18n: i18next or Lingui, compared
- Expo android widgets: expo-widgets on SDK 58
- Tamagui in Expo: setup, styling and themes
- Best paywall tools for Expo in 2026
- Expo widgets on iOS: a working setup
- RevenueCat subscriptions in an Expo Router app
- Supabase auth in Expo: email, Apple and Google
- Expo push notifications: setup, token, testing
- Expo Go vs development build: limitations
- The best Expo boilerplates, compared