Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 8 min read

Expo Environment Variables: What Is and Is Not Secret

There are two questions here and they get answered as if they were one. The first — how do I read a config value in my Expo app? — is a five-minute answer. The second — where do I put my API key?— has an answer most people don’t like: not in the app, anywhere, ever. Expo’s EXPO_PUBLIC_prefix is doing you a favour by making that explicit in the variable name. Here’s how the system works and where each kind of value actually belongs.

The one rule

Anything your JavaScript can read at runtime, an attacker can read too. An app bundle is a zip file. Unzip an .ipa, find the JS bundle, run strings on it, and every EXPO_PUBLIC_ value is right there in plain text — because Metro inlines them as literals at build time. There is no obfuscation step that changes this and no secure store that helps, because the value has to be in the binary for the app to use it offline.

# Prove it to yourself in 30 seconds:
unzip -o build.ipa -d /tmp/app
strings /tmp/app/Payload/*.app/main.jsbundle | grep -i "EXPO_PUBLIC\|https://"

# Every publishable value you shipped, listed.

This isn’t an Expo weakness — it’s true of every mobile framework and every web frontend. The useful consequence is that the decision is binary and you can make it per value in about two seconds: would I be fine printing this on a billboard? If yes, bundle it. If no, it goes on a server.

Classify every value before you write any config

ValueIn the bundle?Where it goes
Supabase project URL✅ Safe to bundleEXPO_PUBLIC_SUPABASE_URL
Supabase anon / publishable key✅ Safe to bundleEXPO_PUBLIC_SUPABASE_ANON_KEY — RLS is the real guard
Supabase service_role key❌ NeverServer / Edge Function only
Stripe publishable key (pk_)✅ Safe to bundleEXPO_PUBLIC_STRIPE_PK
Stripe secret key (sk_)❌ NeverYour backend, which returns a client secret
OpenAI / Anthropic API key❌ NeverBackend proxy route
Google Maps mobile API key⚠️ Bundled by designapp.json — restrict by bundle ID + SHA-1
Sentry DSN✅ Safe to bundlePublic by design
Sentry auth token (source maps)❌ NeverEAS secret, build-time only
Your own API base URL✅ Safe to bundleEXPO_PUBLIC_API_URL

The Supabase anon key confuses people because it looks like a credential. It is one — a deliberately low-privilege one, designed to be public. What protects your data is row-level security on the tables, not the secrecy of that key. If your RLS policies are wrong, hiding the anon key wouldn’t have saved you; if they’re right, publishing it costs nothing.

The setup: files, prefixes, precedence

# .env.example  — COMMIT this one
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJ...

# .env.local     — gitignored, your machine
# .env.production — gitignored, real values

# .gitignore
.env
.env.local
.env.*.local
.env.production

Expo loads these in a fixed order and a real shell variable always wins: process.env from your shell or CI, then .env.local, then .env.[mode].local, then .env.[mode], then .env. When a value seems stale, that precedence chain plus Metro’s cache is nearly always the reason.

// lib/config.ts — read once, validate loudly, never sprinkle process.env
function required(name: string, value: string | undefined) {
  if (!value) throw new Error(`Missing ${name}. Check your .env file.`);
  return value;
}

export const config = {
  apiUrl:  required('EXPO_PUBLIC_API_URL',  process.env.EXPO_PUBLIC_API_URL),
  supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL', process.env.EXPO_PUBLIC_SUPABASE_URL),
  supabaseAnonKey: required(
    'EXPO_PUBLIC_SUPABASE_ANON_KEY',
    process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
  ),
} as const;

One thing that trips everyone: you cannot destructure or index process.env. Metro does a literal text substitution on process.env.EXPO_PUBLIC_FOO, so const { EXPO_PUBLIC_FOO } = process.env and process.env[key] both produce undefined at runtime. Write the full property access every time.

Per-environment builds with eas.json

// eas.json
{
  "build": {
    "development": {
      "developmentClient": true,
      "env": { "EXPO_PUBLIC_API_URL": "http://192.168.1.20:3000" }
    },
    "preview": {
      "distribution": "internal",
      "env": { "EXPO_PUBLIC_API_URL": "https://staging.example.com" }
    },
    "production": {
      "env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
    }
  }
}

# Build-time-only values that must not reach the bundle:
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value sntrys_...
eas secret:list

EAS secrets solve “keep it out of git”, not “keep it out of the app.” A Sentry source-map upload token is the perfect case — the build worker needs it, the phone never does. If you find yourself wanting an EAS secret whose value is then read by app code, you have a server-side problem wearing a config disguise.

The pattern for real secrets

Say your app calls an LLM. The key cannot be in the app, so the app calls something you control, and that thing holds the key. A Supabase Edge Function is about fifteen lines:

// supabase/functions/ask/index.ts  — runs on the server
Deno.serve(async (req) => {
  // 1. Verify the caller. Without this you built a free public proxy.
  const jwt = req.headers.get('Authorization');
  const { data: { user } } = await supabase.auth.getUser(jwt);
  if (!user) return new Response('Unauthorized', { status: 401 });

  // 2. Rate limit per user — a leaked endpoint is a leaked bill.
  if (await overQuota(user.id)) return new Response('Quota exceeded', { status: 429 });

  // 3. The secret only ever exists here.
  const r = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!,
      'anthropic-version': '2023-06-01',
      'content-type': 'application/json',
    },
    body: await req.text(),
  });
  return new Response(r.body, { headers: { 'content-type': 'application/json' } });
});

Steps 1 and 2 are the ones people skip, and they matter more than the key hiding. An unauthenticated proxy endpoint is functionally the same as publishing the key, except the bill arrives faster because someone found it with a scanner.

Quick debugging table

  • Value is undefined: missing EXPO_PUBLIC_ prefix, or you destructured process.env.
  • Value is stale after editing .env: npx expo start --clear. Inlined literals are cached in the Metro transform cache.
  • Works locally, undefined in an EAS build: your .envis gitignored (correctly) and never reached the build worker. Put it in the profile’s env block or an EAS secret.
  • Need it in app.json: rename to app.config.jsand return a JS object — static JSON can’t read env vars.

Start from a project that already draws the line

The most expensive version of this mistake is finding a service-role key in a shipped bundle after launch. Apps generated by ShipNative come with the split already made — publishable values in EXPO_PUBLIC_ config, privileged operations behind server-side policies, and a .env.example that documents what a new machine needs. You export the whole Expo project, so nothing here is hidden from you.

Frequently Asked Questions

How do environment variables work in Expo?

Expo reads .env files at build time and inlines any variable prefixed with EXPO_PUBLIC_ directly into your JavaScript bundle as a literal string. Variables without that prefix are visible to app.config.js and to your build scripts, but are undefined in app code at runtime.

Are EXPO_PUBLIC_ variables secret?

No. They are compiled into the bundle as plain text. Anyone can unzip an IPA or APK, run strings on the JavaScript bundle, and read every EXPO_PUBLIC_ value. Treat them exactly like something printed on the app's About screen — fine for a project URL or a publishable key, never for anything that grants write access.

What is the difference between EAS secrets and EXPO_PUBLIC_ variables?

EAS secrets are values stored on Expo's servers and injected into the build environment on the build worker. They keep a value out of your git repository, which is genuinely useful for signing credentials and build-time tokens. They do not keep it out of the shipped app: if a secret is referenced by an EXPO_PUBLIC_ variable it still ends up in the bundle.

Why is process.env.MY_VAR undefined in my Expo app?

Because it lacks the EXPO_PUBLIC_ prefix. Metro only inlines prefixed variables into app code; everything else is stripped. Rename it to EXPO_PUBLIC_MY_VAR, and restart the bundler with npx expo start --clear, since inlined values are cached.

Should I commit my .env file in an Expo project?

Commit .env.example with the keys and dummy values so a new machine knows what is required, and gitignore the real .env files. Even for EXPO_PUBLIC_ values that will ship publicly anyway, keeping them out of the repository means you can rotate a project or endpoint without a code change.

Where should I put a real API secret in a mobile app?

On a server. Put the secret in a backend route, an Edge Function, or a Cloud Function, have the app call that endpoint with the user's auth token, and let the server talk to the third-party API. There is no client-side location — not native code, not obfuscation, not secure storage at build time — that keeps a shipped secret from a determined reader.

→

Connect an AI-Generated App to a Real Backend

Where these variables get used once you have a live database.

Read guide →
→

Auth for AI-Built Apps

The keys, tokens, and sessions that sit next to this config.

Compare →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.