# EAS build secrets stay out of bundles when env profiles and eas.json split right

> Service keys baked into Expo bundles are public the moment you ship. This profile split keeps them out.
> By Dave · 2026-09-05
> Source: https://otf-kit.dev/blog/eas-build-secrets-env-profiles-production

A secret that ships inside your JavaScript bundle is not a secret. In Expo production builds, the most common leak is not a hacked server — it is a service key baked into the bundle through a public environment variable, then published to a store listing that anyone can download and unpack.

This post lays out a practical split for EAS builds: public config that is safe to embed, server-side secrets that never enter the bundle, and the profile setup that keeps staging and production from cross-contaminating each other.

If you are catching up on the release lane, start with [/blog/eas-update-rollback-plan](/blog/eas-update-rollback-plan) — the rollback plan assumes your builds are reproducible, and reproducible builds start with clean environment separation.

## Why builds leak secrets

Expo embeds any variable prefixed for public use into the JavaScript bundle at build time. That is by design: the client needs public values such as the project identifier and the public API endpoint at runtime, and there is no server to ask when the app runs offline.

The failure mode is using the same mechanism for a privileged key. A database service-role key, a push provider server key, or a payment webhook secret placed in a public variable ends up as a plain string in shipped JavaScript. Anyone who downloads the binary can extract it with off-the-shelf tooling.

The rule is simple: if a key can write, delete, refund, or impersonate, it never enters the client bundle. It lives on your server or in an edge function, and the app calls that endpoint with a user-scoped token instead.

This mirrors the server-side half of the pattern in [/blog/supabase-rls-production-checklist](/blog/supabase-rls-production-checklist) — row-level policies are the enforcement point that makes a leaked anon key survivable and a leaked service key catastrophic.

## Sort every value into one of three buckets

Before touching config files, list every environment value your app uses and assign each to exactly one bucket:

1. Public build-time config. Safe to embed. Examples: the public API base path label, the feature flag defaults, the public analytics write key scoped for client use.
2. Build secrets. Needed during the EAS build but must not ship. Examples: store service account credentials, code-signing tokens, private registry tokens for installing dependencies.
3. Runtime server secrets. Needed by your backend only. Examples: database service-role keys, webhook signing secrets, third-party server tokens.

Bucket one goes in public variables. Bucket two goes in EAS build secrets attached to the build profile. Bucket three lives in your server hosting provider and never appears in the mobile repo at all.

Write this list down in your repo docs. The next engineer who onboards will otherwise guess, and guessing is how a service key lands in a public variable at midnight before a release.

## Configure eas profiles so staging cannot poison production

Define explicit build profiles so each lane carries only its own values. A minimal structure that works for most teams:

```json
{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": {
        "APP_VARIANT": "development"
      }
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "APP_VARIANT": "preview"
      }
    },
    "production": {
      "distribution": "store",
      "env": {
        "APP_VARIANT": "production"
      }
    }
  }
}
```

Keep the production profile free of any staging overrides. When you need a staging backend for preview builds, put those values in the preview profile only, and name them so the lane is obvious at a glance.

Three profile rules that prevent the classic mix-ups:

- Production builds run from the production profile and nothing else. No command-line overrides on release day.
- Preview builds point at staging backends with staging credentials that are safe to rotate. If a preview binary leaks, you rotate staging, not production.
- Development builds never carry production secrets, even temporarily. A debug binary on a test device with production keys is a production incident waiting for a lost phone.

## Keep public and private variables apart in code

Read public config through a single module so the boundary is visible and reviewable:

```typescript
const publicConfig = {
  appVariant: process.env.APP_VARIANT ?? 'development',
  apiBaseLabel: process.env.EXPO_PUBLIC_API_LABEL ?? 'default',
} as const;

export function getPublicConfig() {
  return publicConfig;
}
```

Then enforce the boundary with a review rule: any new public variable must be justifiable as safe-to-ship in the pull request description. If the author cannot explain why the value is harmless in a public bundle, it does not get the public prefix.

On the server side, keep a parallel module that reads runtime secrets from the hosting environment and never imports anything from the client tree. The two modules should not share files, because shared files get bundled by accident when an import chain shifts.

```typescript
import 'dotenv/config';

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required server variable: ${name}`);
  }
  return value;
}

export const serverSecrets = {
  serviceRoleKey: requireEnv('SERVICE_ROLE_KEY'),
  webhookSecret: requireEnv('PAYMENTS_WEBHOOK_SECRET'),
};
```

Failing fast at server boot beats discovering a missing secret from a crash report. The throw names the variable without printing its value, so logs stay useful without becoming a leak vector.

## Treat over-the-air updates as config-frozen

Over-the-air updates change JavaScript, not native binaries and not build-time environment. If you push an update that reads a new public variable, devices running the old binary still carry the old embedded value until a full store update ships.

This has two consequences for secrets work:

- Never use an over-the-air update to rotate a compromised client-visible value and assume coverage. Ship a new binary through the store and keep the rotation window open until adoption crosses your threshold.
- Keep the set of public variables stable between releases. Adding, renaming, or removing one is a native-build event in practice, so batch those changes with your normal binary release train.

The rollback story in [/blog/eas-update-rollback-plan](/blog/eas-update-rollback-plan) covers the bundle side of this. The environment side follows the same principle: know which layer each value lives in before you promise a fix timeline.

## Audit what actually shipped

Trust the binary, not the config file. After a production build, verify what is inside before submission:

```bash
eas build --platform all --profile production
eas credentials --platform ios
eas credentials --platform android
```

Then run these checks as part of your release gate:

1. Search the built bundle for each server secret name. The name itself should not appear; if the name appears, the value is one step behind.
2. Confirm the signing identity matches the store listing owner. A build signed with the wrong keystore breaks update continuity on Android and notification entitlements on iOS.
3. Install the store-signed candidate on a clean device with no prior install and complete first launch, sign-in, and one paid or privileged action end to end.
4. Record the build number, profile name, and backend lane in your release notes so a later incident review can trace which values a given binary carries.

Store submission details, including what reviewers see versus what ships in the binary, are covered in [/blog/app-store-submission-checklist-ai-built-app](/blog/app-store-submission-checklist-ai-built-app).

## Rotate without drama

Keys leak through logs, screenshots, and former contractors. Plan rotation as a routine operation, not an emergency procedure:

- Keep staging credentials disposable so preview-lane exposure costs one rotation script, not a postmortem.
- Scope production keys as narrowly as the provider allows, so each key covers one capability and rotation affects one path.
- Maintain a written rotation order: server secrets first, then backend-issued client tokens, then a new binary if any client-visible value changed.
- After rotation, verify the old key is dead by exercising the old value against a staging endpoint and confirming rejection.

The teams that rotate calmly are the teams that rehearsed it. Run the rotation drill on staging once a quarter and time it. If it takes longer than your incident tolerance, automate the slowest step before the next release.

## Ship checklist for secret hygiene

- Every environment value is sorted into public config, build secrets, or server-only secrets.
- No privileged key carries the public prefix or appears in client imports.
- Production profile builds with store distribution and no staging overrides.
- Server secrets live in hosting config, load at boot, and fail fast when missing.
- Over-the-air updates are never used to rotate embedded values.
- Release gate searches the bundle for secret names and tests a clean install.
- Rotation order is written down and rehearsed on staging.

Get this split right and your EAS pipeline becomes boring in the best way: builds carry exactly what they need, servers hold the rest, and no single mislabeled variable turns a routine release into a credential incident.

## Sources

- [Expo Application Services](https://docs.expo.dev/eas/)
