Articles · GuidesUpdated October 2026

Keeping API keys out of a shipped app is one question, asked of each key: phone or server?

Every key that can spend money, read other people's data or change your account lives on a server you control, and the app asks that server for what it needs. That is the whole technique. The rest of this page is about telling those keys apart from the ones that are designed to be public, and about what to do when the wrong kind is already sitting in a build people have installed. Expo's own documentation gives the reason in one sentence: when an end-user runs your app, they have access to all of the code and embedded environment variables in your app. Read on 16 October 2026, on a page Expo last modified on 12 October 2026.

What follows is what the EXPO_PUBLIC_ prefix actually does to a value, which keys the providers themselves mark as shippable, and the order of operations when one has leaked. That last part is the one people get backwards. Rotating the key is not the last step, and shipping an update is not the fix.

Start here

The short version

A shipped app is a published document. Sort, do not hide.

There is no mechanism in an app bundle that hides a value from the person running the app, because the app has to be able to read the key in order to use it. Treating this as a hiding problem produces clever tricks that buy nothing. Treating it as a sorting problem produces an app that is boring to attack.

So split your credentials in two. Keys the provider describes as publishable, anonymous or client side go in the app, because that is what they were made for. Keys described as secret, admin or service, anything metered per call, and anything that is really a password go on a server of your own that the app talks to. If the documentation does not say which kind you are holding, treat it as the second kind until you find a sentence that says otherwise.

Is anything in a shipped app actually hidden?

No. Not a string in a component, not a value in a property list, not a file you dragged into the project. A shipped app is a file people download, and a file people download is a file people can open. Expo states the consequence plainly: when an end-user runs your app, they have access to all of the code and embedded environment variables in your app. No build setting changes that, and no bundler option does either.

Most of the confusion comes from mixing up two different protections. Storing data securely on a device protects the person using the app from somebody who picks up their phone. Apple's keychain article is explicit about whose secrets that is for: its summary is about relieving the user of remembering small secrets. Your API key is not a user secret. It is a credential that belongs to your business and happens to be sitting on a stranger's hardware, which is a different problem with a different answer.

Obscuring does not count either. Splitting the key across three strings, encoding it, assembling it at startup, downloading it from an endpoint of your own that anybody can call: each of these makes the key slightly harder to find and exactly as easy to use once found. The honest threat model is that anyone sufficiently annoyed can read it, and the only real question is how much damage reading it does. That question has a different answer for every key in your project, which is why the sorting comes first.

So the job is not hiding keys. The job is deciding which of your credentials were designed to be handed out, and making sure the rest are never referenced by app code at all. Most apps need two or three of the first kind and one or two of the second. The second kind is the reason you end up with a server, and it is worth knowing that before you build, not after.

Expo, environment variables in Expo, read 16 October 2026, page last modified 12 October 2026

Sort yours

Phone or server?

Pick the kind of credential you are holding. The wording is the wording each provider uses in its own documentation.

Put it in the app

A publishable or client key

The documentation will say so in words like publishable, client side or anonymous. Stripe marks its pk_ key safe to expose and says you can include it in front-end code or applications you distribute. Still read it from an EXPO_PUBLIC_ variable rather than typing it into a component, so test and live values are a file change and not a code change.

When the documentation does not say, assume server. A key you cannot find a sentence about is a key nobody has decided about.

What does EXPO_PUBLIC_ do to a value?

It copies the value into your bundle. Expo CLI automatically loads environment variables with an EXPO_PUBLIC_ prefix from your .env files and replaces all references in your code to process.env.EXPO_PUBLIC_[VARNAME] with what it found. Expo's own example spells out the result: process.env.EXPO_PUBLIC_API_URL will be replaced with https://staging.example.com in your app bundle. The variable is not looked up when the app runs. It is substituted before the app ships, which means it is sitting in the file your users download.

The prefix is a statement of intent, not a security boundary, and Expo says so twice on the same page. Do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables, because these variables will be visible in plain-text in your compiled application. And again, later: never store sensitive secrets in environment variables that are prefixed with EXPO_PUBLIC_. One carve-out is worth knowing because it surprises people debugging a library: Expo says code inside node_modules is not affected, for security purposes.

Which file supplies the value depends on the command you ran. In development Expo reads .env.development.local, then .env.local, then .env.development, then .env, with the files listed first taking priority. In production it reads the .production equivalents in the same order. From SDK 58, Expo commands do not load other .env files, so a .env.staging sitting in your repository does nothing by itself. Expo also recommends adding .env.local files to your .gitignore, which is the cheapest habit on this page.

On EAS the same substitution happens inside the build. EAS Build uses Metro Bundler to build the JavaScript bundle embedded within your app binary, so whatever values are present when that bundle is built are the values your users get, and EAS Update works the same way. Expo's documentation for environment variables in EAS, read on 16 October 2026, adds the part that catches people out: a variable marked secret is not readable outside of the EAS servers, including on the website and in the EAS CLI, yet it still reaches the build job. Expo is direct about what that buys you. Secrets do not provide any additional security for values that you end up embedding in your application itself. Secret visibility protects the value in the dashboard and in the logs. It does not protect it in the binary.

Expo, environment variables in Expo, on .env precedence and the EXPO_PUBLIC_ warnings, read 16 October 2026

Which keys are safe to put in the app, and which are not?

Some keys exist in order to be shipped. Stripe publishes the clearest version of the split and the vocabulary carries over to other services, so it is worth reading even if you never take a payment. Its publishable key, prefixed pk_, is marked safe to expose. Stripe says it can identify your account and create tokens or PaymentMethods from payment details, but that it cannot perform sensitive operations such as creating charges or reading account data, and that you can include it in front-end code or applications you distribute.

Every other key on that list is marked not safe to expose. The secret key, prefixed sk_, has unrestricted permissions on all Stripe APIs. The restricted key, prefixed rk_, has permissions you choose yourself, and Stripe still marks it not safe to expose. That row is the one people misread. Narrowing what a key can do limits the damage if somebody takes it, which is exactly why Stripe now recommends restricted keys over secret keys for new work, but it does not turn a server credential into a client one. Checked 16 October 2026.

Read that pattern onto whatever your app talks to. A key the documentation calls publishable, anonymous or client side belongs in the app. A key it calls secret, admin or service belongs on a server, and so does anything billed per call, because a metered key in a public bundle has no spending ceiling. A webhook signing secret is a third thing entirely: it exists so your server can verify that a request really came from the provider, those requests never arrive at a phone, and Stripe keeps them separate from API keys for that reason. There is no build of your app that needs one.

This is why adding payments is usually the moment an app acquires a server for the first time. The publishable key goes in the app, the charge is created on your side, and the secret key never appears in anything you upload. Stripe also documents one guardrail you can switch on without touching code: an access policy attached to a live key blocks requests from sources you did not allow and notifies you when somebody tries, which is a cheap way to learn that a key has walked.

Stripe, API keys, on which key types are safe to expose, read 16 October 2026

A key is already in a build people installed. What now?

Work in this order, because two of the five steps are routinely done backwards. First, find out what the key can do. A publishable key that leaked is not an incident and needs no action. A secret key is an incident, and so is anything billed per call. Second, look at the usage before you change anything. Stripe keeps request logs per key and lets you open them from the key itself, and those logs are how you find out whether anybody other than you has been using it.

Third, kill the key, and choose how deliberately. Rotating a key in the Stripe dashboard revokes it and issues a replacement that works immediately, and by default the old and new keys both keep working for up to seven days so you can migrate without downtime. That grace period is a convenience when a colleague has left and a liability when the key is public, because the old key is still live all week. The rotation dialog has an expiration dropdown, and choosing Now deletes the old key straight away. You can also expire a key outright, after which Stripe says code that uses it can no longer make API calls and you must create a new one and update anything that used it. Pick the option that matches the situation rather than the one that avoids an outage.

Fourth, ship the replacement. On a native app that means a new build and the usual store wait, which is the painful part and also the reason the order matters: the old key has to be dead before the fix is live, not after it. Nothing you ship removes a value from a build that is already on somebody's phone, so the rotation is the fix and the update is only cleanup. This is also the one release you do not want to trickle out slowly, so if you normally ship through a phased release, turn it off for this one and let everybody have it at once.

Fifth, remove the reason it happened. Usually that means a small endpoint of your own that holds the real key, checks who is asking, adds the key and forwards the call. Being honest about the cost matters here, because the endpoint is a service that runs, gets patched and occasionally wakes you up, and it belongs in what an app costs to maintain rather than being found out six months later. The alternative, leaving a metered key in the app and watching the invoice, is not free either. It is just a cost you do not control.

Stripe, API keys, on rotating, expiring and reading request logs for a key, read 16 October 2026

Where the value actually ends up

Four of these six places feel safer than they are. The column that decides everything is the second one, because once a value is in the bundle, the third column follows from it.

Where you put the valueEnds up in the bundleReadable by anyone who runs the appHome for a real secret
Typed straight into a componentYesYesNo
An EXPO_PUBLIC_ variable in a .env fileYesYesNo
An EAS variable marked secret, read by app codeYesYesNo
An EAS variable marked secret, used only by the buildNoNoBuild time only, not a runtime secret
A keychain entry your app writes at runtimeNoOnly on that one phoneFor the user's own secrets, not yours
An endpoint of your own that the app callsNoNoYes

Where this lands if a builder generated your app

The rule does not change because an agent wrote the code, but who can act on it does. Newly is an AI app builder. You describe an app in plain English and it writes a real React Native and Expo project that you own. It uploads iOS builds to TestFlight, publishes to Google Play internal testing, builds a standalone Android APK, and keeps two way GitHub sync so the code sits somewhere you can read it. It is $25 a month and there is no free tier.

Owning the project is what makes this particular problem fixable. You can open the repository, search it for anything shaped like a key, read every .env file in it, and move the values that should not be there. With a tool that hands you a configuration inside its own account instead of a codebase, you cannot run that search at all, and you are taking somebody's word for where your credentials live. That is a reasonable thing to check before you commit to any builder.

The limit is the same one Apple sets everywhere else. Newly does not submit to the App Store for you, because App Store Review Guideline 4.2.6 requires apps created from a commercialized template or app generation service to be submitted by the provider of the app's content. You hold the developer account, you press submit, and the keys inside that build are yours to get right. Budget the store side on its own: what it costs to publish an app covers the fees that are yours whichever way you build.

Questions people ask about keys in a shipped app

No. Expo's documentation states that when an end-user runs your app, they have access to all of the code and embedded environment variables in your app, and warns that values in EXPO_PUBLIC_ variables will be visible in plain-text in your compiled application. The phone has to read the key in order to use it, so the key is readable. Splitting it across strings, encoding it or fetching it from an unauthenticated endpoint of your own makes it harder to find and no harder to use once found. The working approach is to decide which keys are designed to be public and keep the rest on a server the app calls.

Sort the keys before the first build, not after the invoice

Write down every service your app will call and which kind of key each one hands you. The ones marked publishable go in the app. The rest decide whether you need a server, and that is a much cheaper thing to know on day one.

Start building