How to Manage Environment Variables and Secrets in an Expo App

DavidDavid September 8, 2026 9 min read
A matte black smartphone showing a blank app splash screen on a dark wooden desk beside a small brass key, a closed padlock, and a folded config note under a single warm lamp
Original image, App Store Launch Club

The One Distinction That Decides Everything

Before you touch a config file, sort every value your app reads into one of two buckets: public or secret. A public value is one you would not mind a user finding if they took your app apart, because they can. A public analytics measurement id, the base URL of your own API, a feature flag, a bundle identifier: these are fine to ship inside the app. A secret is anything that grants access or spends money if someone else has it: a private API key, a database credential, a payment provider's secret key, a service token. The two get wired completely differently, and the mistake that causes almost every leak is treating a secret like a public value.

The reason this matters so much on mobile is that a shipped app is not a private vault. A release build is a bundle of your compiled JavaScript and assets sitting on a stranger's phone, and anyone with the file can unzip it and read the strings inside. There is no server-side hiding place in the app itself. So the rule is blunt: if a value being extracted from your app would let someone do something you would not allow, that value must never be in the app. It lives on a server your app talks to, and the app only ever holds a token that server can revoke.

How Public Values Reach Your App at Runtime

Public values have two clean paths in Expo, and both end up readable inside your running app. The first is the extra field in your app config (app.json or app.config.js), which you then read through expo-constants as Constants.expoConfig.extra. This is the classic route for values that belong to a build: an API base URL, a public key, an environment name. Because app.config.js is JavaScript, you can populate extra from process.env at build time, which lets you keep the actual values in EAS rather than hardcoded in the file.

The second path is the EXPO_PUBLIC_ prefix. Any variable named EXPO_PUBLIC_SOMETHING is inlined into your JavaScript bundle at build time and read in code as process.env.EXPO_PUBLIC_SOMETHING. This is convenient, but the name is a promise you must keep: the word PUBLIC means the value ends up in the shipped bundle in plain text. It is the right tool for a genuinely public value and the wrong tool for anything else. Never put a private key behind EXPO_PUBLIC_ and assume the prefix hides it - it does the opposite, it guarantees the value is in the client.

Where each kind of value belongs

ValuePublic or secretWhere it goes
API base URL of your own backendPublicapp config extra, or EXPO_PUBLIC_ variable
Public analytics / measurement idPublicEXPO_PUBLIC_ variable
Private third-party API keySecretA server you call - never in the app
Payment provider secret keySecretServer only; the app uses the publishable key
EAS / signing credentialsSecretEAS environment variables, build-time only

How Secrets Reach an EAS Build Without Reaching the User

There is a real and important difference between a secret your app needs at runtime and a secret your build needs at build time. Build-time secrets - a credential a build step calls, a token used to fetch a private dependency, a key a config plugin reads while assembling the binary - never need to end up in the shipped bundle. Those belong in EAS environment variables, created with a sensitive or secret visibility and scoped to the build profile that needs them. EAS injects them into the build environment on Expo's servers, the build uses them, and they are not written into your JavaScript unless you explicitly pass them through to the client (which, for a secret, you never do).

Runtime secrets are the ones people get wrong. If your app needs to call a service that requires a private key, the app does not hold that key. Instead you stand up a thin server endpoint - a serverless function is enough - that holds the secret, and your app calls your endpoint. Your endpoint calls the third party with the secret and returns only the result. The private key never leaves your server, the app only ever talks to a surface you control, and if a token the app does hold is ever abused, you revoke it server-side without shipping an app update. This is the same discipline that keeps an unattended endpoint from becoming an open faucet: a key that can spend money never sits somewhere the whole internet can reach it.

Why the Local .env Trap Catches So Many Builds

The most common environment bug in an Expo app is not a leak, it is an absence: a value that lived in a .env file on your machine and was simply never present in the release build. EAS builds run on Expo's servers, not your laptop, so a plain .env file that you did not wire into app config or register as an EAS variable is invisible to the build. The app reads the value, gets undefined, and crashes on launch in production while running fine in development. If you have hit that exact symptom, the walkthrough in [why your Expo app crashes on launch in a production build](/blog/expo-app-crashes-on-launch-in-production-build) traces it to the missing-value cause and the fix.

The discipline that prevents both the leak and the absence is the same: name every variable, decide public or secret, and give it a single home that the production build can actually see. Public values go in app config or an EXPO_PUBLIC_ variable that you have registered so it exists at build time. Secrets go in EAS as sensitive variables or, if they are runtime secrets, stay on a server entirely. A .env file is fine for local development, but it is a development convenience, not a delivery mechanism - the build has to be told about every value some other way.

  1. List every place your code reads a config value or a key.
  2. Label each one public or secret with the extraction test: could a user pull this from the app and misuse it?
  3. Wire public values through app config extra or an EXPO_PUBLIC_ variable, and confirm each is defined for the production build profile, not just locally.
  4. Move every secret out of the client: build-time secrets into EAS environment variables, runtime secrets behind a server endpoint the app calls.
  5. Add .env to .gitignore so a real value never lands in your commit history, where it is just as exposed as in a bundle.

Reading Values Safely in a Production Build

How you read a value matters as much as where you store it, because a value read at the wrong moment or without a fallback is its own crash. Do not read a config value at the top level of a module, outside any function, where it runs the instant the file is imported. If that value is undefined in production, the throw closes the app before a single screen renders. Read config inside a function or component that runs after the app has mounted, so a missing value becomes a handled error you can show, not a dead launch.

Give every value a sane default and a guard. If an EXPO_PUBLIC_ variable is missing, decide whether the app should fall back to a safe default, show a clear error, or degrade a feature - never let it silently become undefined and propagate. A short validation step at startup that checks the handful of values the app truly cannot run without, and fails loudly with a readable message rather than a raw crash, turns a mysterious production-only close into a one-line diagnosis. This is cheap to add and it pays for itself the first time a build ships with one variable unset.

How to Work This With Claude Code

Sorting values into public and secret is exactly the kind of audit that goes fast with an assistant reading your actual project. Open Claude Code's desktop app in your app's folder and ask it to find every place your code reads process.env or a config value, then classify each as public or secret using the extraction test above. Because it can see your app config, your EAS variable references, and the files that read them, it can flag a private key sitting behind an EXPO_PUBLIC_ prefix, a value read at module load that will crash in production, or a secret hardcoded in a committed file.

From there it can do the mechanical work: move a build-time secret into an EAS environment variable, rewrite a top-level read into a guarded one inside a function, add a startup validation check for your required variables, and confirm .env is ignored by git. The terminal is there if you prefer to run the EAS commands yourself, but the desktop app can run them for you once you have decided which value belongs where. The judgment - public or secret - is yours; the wiring is the part to hand off.

Members inside App Store Launch Club post their app config and their variable setup before they ship, and someone who has already leaked a key the hard way sanity-checks it first. Join at applaunchclub.co for $9 a month and get a second pair of eyes on which values are safe to ship before the build goes out, not after.

Free app-building tips, straight to your inbox

Short, practical drops on app ideas, validating fast, store listings, and passing app review. No spam, unsubscribe anytime.

Frequently asked questions

What is the difference between an EXPO_PUBLIC_ variable and a secret in an Expo app?

An EXPO_PUBLIC_ variable is inlined into your JavaScript bundle at build time and is readable by anyone who takes the shipped app apart, so it is only for genuinely public values like a public analytics id or your own API base URL. A secret is any value that grants access or spends money, and it must never be in the bundle at all. Secrets that a build step needs go in EAS environment variables with sensitive visibility; secrets your app needs at runtime stay on a server the app calls, and the app holds only a revocable token.

Where should I store a private API key in an Expo app?

Not in the app. A shipped release build is a bundle of files on a user's phone that can be unzipped and read, so any key inside it is effectively public. Put the private key on a server you control - a serverless function is enough - and have your app call your endpoint, which calls the third party with the secret and returns only the result. The key never leaves your server, and you can revoke the app's token without shipping an update.

Why does my app read undefined for an environment variable only in production?

EAS builds run on Expo's servers, not your machine, so a value that only lived in a local .env file was never given to the release build. Wire public values through app config extra or a registered EXPO_PUBLIC_ variable, and register secrets as EAS environment variables, so the production build profile can actually see each value. A local .env is a development convenience, not a delivery mechanism - the build has to be told about every value some other way.

Is it safe to commit a .env file to my repository?

No. A real value in a committed .env file is exposed in your commit history to anyone with access to the repo, which is just as much a leak as shipping it in the bundle. Add .env to .gitignore before you add any real value, and if a secret has already been committed, rotate it - removing the file does not remove it from history. Use EAS environment variables or a server for anything that is actually a secret.

How do I read an environment variable safely so it does not crash my app?

Do not read config at the top level of a module where it runs at import time, because an undefined value there throws before any screen renders and closes the app on launch. Read config inside a function or component that runs after the app mounts, give each value a sane default or a guard, and add a short startup check that fails with a readable message if a required value is missing. That turns a silent production-only crash into a one-line diagnosis.

Last reviewed by David on September 8, 2026

David

Written by

David

Founder and app builder

Keep reading

App StoreLaunch

App Store Phased Release: How the 7-Day Rollout Works, How to Pause It, and When to Skip It

App Store phased release rolls a version update out to a random sample of users with automatic updates on over 7 days: 1 percent on day one, then 2, 5, 10, 20, 50 and 100. You can pause it for up to 30 days in total, or release to everyone with one button. Here is exactly what Apple says it does, what it does not do, and how I decide when an Expo app update should use it.

David 8 min
Read article
ExpoClaude Code

How to Add Haptic Feedback to Your Expo App

How to add haptic feedback to your Expo app: install expo-haptics, then call the right method for the moment - selectionAsync for pickers, impactAsync for taps and gestures, notificationAsync for outcomes. Here is the exact mapping, the reason the simulator stays silent, and the one rule that keeps haptics from feeling cheap.

David 8 min
Read article

Ready to build it yourself?

Join App Store Launch Club, the #1 community for building and launching apps, for $9/month.

← Back to the blog