For a React Native developer who just realized an EXPO_PUBLIC_STRIPE_KEY or an extra.apiSecret was added to app.json a few sprints ago, the question is simple: does that string actually live inside the IPA and APK now on user devices? The honest answer is yes, and it is not even hard for a curious user to read.
Short answer
The extra field in Expo's app.json (or app.config.js) is bundled into the public Expo config that ships inside every iOS and Android build, and is exposed at runtime through Constants.expoConfig.extra. The Expo environment variables documentation states that variables prefixed EXPO_PUBLIC_ are visible in plain text in the compiled application, and the same reasoning applies to anything in extra. Both end up in the JavaScript bundle that ships to user devices.
What you should know
- The extra field is public by design. Per the Expo app config documentation, values in
extraare included in the public app config and are accessed at runtime throughConstants.expoConfig.extra. - You can read your own public config from the command line.
npx expo config --type publicprints exactly what will be bundled into the build and visible to users. - EXPO_PUBLIC_ variables are inlined into the JS bundle. The Expo environment variables guide is direct: anything prefixed
EXPO_PUBLIC_is replaced at build time and stays visible in plain text. - Plugin config is part of the public config too. A community report against
sentry-expoconfirmed that anauthTokenplaced in app.json plugin config was readable from a production build viaConstants.expoConfig. - OWASP MASVS-STORAGE-1 still owns the rule. Any long-term secret embedded in the JavaScript bundle violates the storage requirement that credentials should not ship inside the application package.
What does the extra field actually do in app.json?
The extra key in app.json is the documented way for an Expo app to carry arbitrary configuration values into runtime. Per the Expo configuration docs, the contents of extra become readable as Constants.expoConfig.extra from anywhere in the JavaScript bundle once the build is installed. Many tutorials suggest using it for things that change between staging and production, such as a backend URL or a feature flag.
That convenience is the trap. The same docs note that extra belongs to the public app config, alongside slug, name, version, and any plugin configuration. It exists so the JavaScript layer can read it; it cannot also stay private. In practice, anything readable by the JS runtime is also readable by anyone who pulls the APK off the device and inspects the bundle.
This claim sits in the confirmed lane. The behavior is documented, predictable, and stable across recent Expo SDK versions.
Where does the extra field end up at runtime?
Inside the compiled JavaScript bundle, as part of an embedded JSON manifest. When EAS Build packages an iOS or Android binary, it serializes the resolved app config and stores it inside the bundle so expo-constants can hand it back at runtime. There is no native encryption layer on this manifest. On Android the bundle lives at assets/index.android.bundle inside the APK. On iOS it is in main.jsbundle inside the IPA.
Per the expo-constants reference, Constants.expoConfig is the standard Expo config object as it was read from app.json or app.config.js. The fields are the same fields a developer wrote, only resolved. That means an extra.firebaseApiKey value reads back as the literal string a user can grep out of the bundle in a few seconds.
The takeaway is structural. There is no on-device transformation that could turn a public manifest value into a private one. Treat every extra value as something the user can read.
Why did Apple flag my Sentry authToken in the bundle?
Because plugin configuration in app.json is also part of the public manifest. The widely cited sentry-expo issue #321 walked through this in detail. A developer placed the Sentry authToken inside the Sentry plugin block in app.json, ran a production build, and then logged Constants.expoConfig?.hooks?.postPublish[0]?.config from inside the running app. The token came back in clear text, complete with the organization and project name.
That report sits in the directional lane in one respect: Expo had documented that sensitive fields are filtered out, which implied something was happening on the server side to scrub keys. In practice, the filter applies to a short list of legacy fields, not to plugin config and not to extra. Anything else passes through.
The lesson holds beyond Sentry. Any builder plugin that asks for an API token, a CI secret, or any long-term credential inside app.json is a leak vector unless the value is also fed in through an environment variable that is not embedded in the build itself.
How can I verify what is in my public manifest before building?
Run npx expo config --type public in the project root. The output is the exact JSON manifest that will be packaged into the next build, with anything Expo considers public visible at the top level. The extra block, the plugin config, the hostUri, and the slug will all be there. Anything you do not want a user to see should not appear in that output.
For a deeper read, decompile a release build and search the bundle. On Android, apktool d release.apk -o out/ followed by a grep for expoConfig against out/assets/index.android.bundle returns the embedded manifest. On iOS, unzip MyApp.ipa and look at the same main.jsbundle. A search for "extra":{ followed by the suspect key name confirms exposure in seconds. This is the same path a static security scanner takes when reviewing a compiled build for hardcoded credentials.
For builders who want an external automated read of a compiled Expo build before submission, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission OWASP MASVS-aligned scanning of APK, AAB, and IPA files for hardcoded API keys, including the Stripe, Firebase, AWS, and OpenAI patterns that often end up in extra by accident.
Where should secrets live if not in extra?
The short answer is: outside the bundle. Three patterns cover almost every case.
The first is EAS environment variables with the sensitive or secret visibility set, configured per environment. Per the Expo environment variables docs, variables marked secret are made available only to the EAS Build worker and are never written into the JavaScript bundle. They are appropriate for things like a code-signing password, a service account JSON, or a Sentry authToken used at build time, not at runtime.
The second is a server-side proxy. Any user-facing API call that requires a paid third-party secret (OpenAI, Stripe live keys, paid geocoding APIs) should be issued from a server endpoint your app authenticates against, never directly from the device. The mobile app holds a short-lived session token, not the upstream API key.
The third is platform key storage for any per-user secret. iOS Keychain via expo-secure-store and the Android Keystore via the same library handle session tokens, refresh tokens, and OAuth credentials. These are user secrets, not app secrets; they belong on the device but not in the manifest.
| Value | Goes in extra | Goes in EAS EXPO_PUBLIC_* | Goes in EAS sensitive/secret env | Goes in server / Keychain |
|---|---|---|---|---|
| Backend base URL | yes | yes | no | no |
| Stripe publishable key | yes | yes | no | no |
| Stripe secret key | no | no | yes (build only) | server only |
| Firebase web API key | yes | yes | no | no |
| Firebase service account JSON | no | no | yes (build only) | server only |
| Sentry DSN | yes | yes | no | no |
| Sentry authToken | no | no | yes (build only) | server only |
| OpenAI API key | no | no | no | server only |
| User access token | no | no | no | Keychain / Keystore |
What to watch out for
The first trap is assuming the Expo build pipeline silently filters secrets. The official statement that sensitive fields are removed applies to a narrow set of legacy fields. Plugin config blocks and extra are not on that list, and the sentry-expo issue confirmed the gap with a working repro.
The second trap is the EXPO_PUBLIC_ prefix used as a safe public channel. It is safe only for values you genuinely want visible. A Firebase web API key, a Mapbox public token, or a backend base URL all fit. A Stripe live secret, an OpenAI key, or a server-to-server auth token do not. The naming convention is a label, never a security boundary.
The third trap is leftover keys after a refactor. A value once placed in extra and later moved to a real secret store can linger in app.json and ship to production simply because nobody removed it. A pre-build hook that diffs npx expo config --type public against a baseline catches this cheaply.
Key takeaways
- The
extrafield in app.json, plugin config in the same file, and anything prefixedEXPO_PUBLIC_in.envfiles all end up in the public manifest bundled into every Expo build. Treat them as user-readable strings. - The Expo documentation states the rule plainly: do not store private keys or other secrets in
EXPO_PUBLIC_variables or in the app config. - The reference path for build-time secrets is EAS environment variables with sensitive or secret visibility. The reference path for runtime secrets is a server endpoint, with platform key storage for the user-bound session token.
- Verify your public manifest with
npx expo config --type publicbefore every release, and grep the compiled bundle for any common credential pattern as a second check. - For builders who want a second opinion on a compiled APK, AAB, or IPA before submission to App Store Connect or Google Play, PTKD.com (https://ptkd.com) is one of the platforms focused on pre-submission OWASP MASVS scanning for hardcoded credentials and exposed config in
extra-style fields.



