Security

    Does the Expo extra field in app.json leak secrets in production?

    A React Native developer running npx expo config --type public against an app.json file to verify which extra constants and plugin tokens end up bundled into a production Expo build, before uploading the IPA to App Store Connect

    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 extra are included in the public app config and are accessed at runtime through Constants.expoConfig.extra.
    • You can read your own public config from the command line. npx expo config --type public prints 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-expo confirmed that an authToken placed in app.json plugin config was readable from a production build via Constants.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.

    ValueGoes in extraGoes in EAS EXPO_PUBLIC_*Goes in EAS sensitive/secret envGoes in server / Keychain
    Backend base URLyesyesnono
    Stripe publishable keyyesyesnono
    Stripe secret keynonoyes (build only)server only
    Firebase web API keyyesyesnono
    Firebase service account JSONnonoyes (build only)server only
    Sentry DSNyesyesnono
    Sentry authTokennonoyes (build only)server only
    OpenAI API keynononoserver only
    User access tokennononoKeychain / 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 extra field in app.json, plugin config in the same file, and anything prefixed EXPO_PUBLIC_ in .env files 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 public before 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.
    • #expo
    • #app.json
    • #secrets
    • #api keys
    • #react native
    • #environment variables
    • #mobile security
    • #owasp masvs

    Frequently asked questions

    Is Constants.expoConfig.extra really readable by anyone with the APK?
    Yes. The extra block is serialized into the JavaScript bundle that ships in every Expo build. On Android the bundle lives at assets/index.android.bundle inside the APK, and on iOS at main.jsbundle inside the IPA. Anyone who downloads the binary, runs apktool or unzips the package, and searches the bundle text will see the literal value of every extra key. There is no per-bundle encryption that would protect it.
    Does using app.config.js instead of app.json hide the values?
    No. The choice between app.json and app.config.js changes how the config is authored, not where it ends up. EAS Build evaluates the config at build time and serializes the resolved manifest into the same public bundle. Switching to JavaScript only helps when it lets you read a value from process.env at build time without committing it to source, but the resolved value still has to stay out of the extra field for the manifest to remain clean.
    What about EAS secrets defined in the EAS dashboard?
    EAS environment variables marked sensitive or secret are available to the build worker but are not embedded in the JavaScript bundle, per the Expo environment variables documentation. They are appropriate for build-time tokens such as a Sentry authToken or a code-signing password. They are not a replacement for runtime secrets that the device would need to call a paid API, since the variable is not present on the device at runtime.
    Will Apple or Google reject my app if I expose an API key in app.json?
    Not directly. App Review and Google Play Review do not statically scan the JavaScript bundle for credential patterns as a documented rejection rule. The real risk is downstream: an exposed Stripe or OpenAI key gets abused, the upstream vendor sees fraudulent traffic, and your account is suspended. A leaked Sentry authToken lets a stranger send fake events into your project. The rejection rarely names the leak; the bill and the support ticket do.
    Does running my app in production mode strip out the extra field?
    No. Production builds use the same public manifest as development builds, only with the bundle minified. Minification renames JavaScript identifiers but leaves string literals alone, so a Stripe key or a Sentry token written as a string in extra survives the minifier untouched. The production label refers to how the build is signed and distributed, not to anything that scrubs the embedded manifest at packaging time.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free