HomeGuidesHow to hide API keys in an Expo or React Native app

Guide

How to hide API keys in an Expo or React Native app

EXPO_PUBLIC_ and .env values ship inside your app. Which keys are safe in a mobile bundle, and how to move OpenAI and secret keys to a server proxy.

20 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

AI coding tools are good at getting a mobile feature working. Ask for an AI chat screen in an Expo app and you may get an EXPO_PUBLIC_OPENAI_API_KEY in .env and a fetch to the OpenAI API straight from a React Native component. It works on your phone. It also hands your key to everyone who installs the app.

This guide explains what actually ends up inside an Expo or React Native build, which keys are fine there, and how to move the rest to a small server route. It covers apps written by hand and apps generated by AI builders. Bolt uses Expo when you ask it for a mobile app, Replit's Agent builds Expo React Native apps, and Rork built Expo apps before switching new projects to Swift and Kotlin (its existing Expo projects keep working). It applies just as much to an Expo app you wrote in Cursor or Claude Code. For the web version of this problem, read keep secrets out of frontend code.

Checked against the Expo, React Native, OWASP, OpenAI, Google, Anthropic, Supabase, Firebase, Stripe, RevenueCat, Apple, and Android docs linked below on October 19, 2026, plus a test export with Expo SDK 58. VibeLocked is independent and not affiliated with any of these products.

The short version

  1. Everything in the app is public. EXPO_PUBLIC_ values, .env files read by react-native-config, and app config extra fields all end up in the build that users download.
  2. Hermes bytecode and obfuscation do not hide strings. In our test export, an EXPO_PUBLIC_ value sat in plain text inside the .hbc file.
  3. Some keys are public by design. Supabase publishable or anon keys (with RLS), Firebase config (with Security Rules and App Check), Stripe publishable keys, and RevenueCat public SDK keys.
  4. AI provider keys and secret keys never ship. OpenAI, Anthropic, Gemini, Stripe secret, Supabase secret or service_role.
  5. Put a proxy in between. An Expo Router API route, Supabase Edge Function, or Firebase callable function holds the key, checks the user, and rate limits.
  6. A shipped key is a leaked key. Rotate it, because old builds stay installed.

What actually ships inside your app

EXPO_PUBLIC_ variables are inlined

Expo's environment variables guide explains that Expo CLI loads EXPO_PUBLIC_ variables from .env files and replaces every process.env.EXPO_PUBLIC_[VARNAME] reference with the value itself. The docs are direct about the consequence: "Do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables. These variables will be visible in plain-text in your compiled application." The security section repeats it: "When an end-user runs your app, they have access to all of the code and embedded environment variables in your app."

The prefix is not a security label. It is an instruction to the bundler: copy this value into the app. That matches how NEXT_PUBLIC_ and VITE_ work on the web, covered in env variables in Cursor, Bolt.new, and Lovable.

.env files and react-native-config do not hide anything

The React Native security page says: "Never store sensitive API keys in your app code. Anything included in your code could be accessed in plain text by anyone inspecting the app bundle." It adds that tools like react-native-dotenv and react-native-config are fine for things like API endpoints, but "they should not be confused with server-side environment variables."

The react-native-config README says the same about itself: the module "doesn't obfuscate or encrypt secrets for packaging, so do not store sensitive keys in .env." A .env file keeps a key out of git. It does not keep it out of the build.

App config extra is public too

Older tutorials pass values through extra in app.config.js and read them with expo-constants. Expo's app config docs say you "should not include any sensitive information in the app config," apart from a few fields that are filtered out (ios.config, android.config, and the update code signing fields). You can see exactly what gets embedded by running npx expo config --type public.

Bytecode is not encryption

Release builds of Expo and React Native apps usually ship Hermes bytecode instead of plain JavaScript. Expo CLI's export command generates it by default for native platforms. That makes the bundle smaller and faster to start. It does not protect strings.

We tested this with a blank Expo SDK 58 app. We set a fake EXPO_PUBLIC_DEMO_KEY, used it in a request header, and ran npx expo export for iOS and Android. The fake key appeared as plain text in both .hbc files, and grep -a found it in a second. OWASP's mobile testing guide also lists hermes-dec, a tool that recovers "a legible version" of the index.android.bundle or main.jsbundle file from Hermes bytecode on Android and iOS.

Obfuscation tools make code harder to read. OWASP's MASVS-RESILIENCE-3 frames anti-static-analysis as an effort to "impede comprehension," not as a way to keep a secret. A key also has to be sent in a request to be useful, so it exists in readable form when the app uses it. Google's Gemini API key docs put it simply: "Keys compiled in client-side code can be extracted by users." OWASP lists hardcoded credentials under M1: Improper Credential Usage, the first entry in its Mobile Top 10, and says to "always avoid using hardcoded credentials in your mobile app's code or configuration files."

EAS environment variables: what visibility protects

EAS lets you store variables on Expo's servers instead of in .env. The EAS environment variables overview lists three visibility levels:

Visibility What Expo says it does
Plain text Visible on the website, in EAS CLI, and in logs
Sensitive Obfuscated in EAS Build and Workflows job logs; can be revealed on the website; readable in EAS CLI
Secret Not readable outside EAS servers, including the website and EAS CLI; obfuscated in job logs

Then come two warnings that matter more than the table. "Anything that is included in your client-side code should be considered public and readable to any individual that can run your app." And secret variables are meant to change how a build job runs, such as an NPM_TOKEN for private packages: "Secrets do not provide any additional security for values that you end up embedding in your application itself."

So visibility protects the value on Expo's dashboard and in logs. It does nothing once your app code reads it. An EXPO_PUBLIC_OPENAI_API_KEY marked secret is still in every copy of the app. Expo's FAQ even advises against giving secret visibility to EXPO_PUBLIC_ variables, because secret values cannot be pulled locally or used to bundle updates.

Which keys are fine to ship, and which never are

Key Ship it in the app? What actually protects you
Supabase publishable key (sb_publishable_) or legacy anon key Yes Row Level Security on every table
Firebase config, including its API key Yes Security Rules, App Check, API key restrictions
Stripe publishable key (pk_) Yes It cannot create charges or read account data
RevenueCat public SDK key Yes It is limited to non-potent subscriber changes
OpenAI, Anthropic, or Gemini API key Never Server proxy
Supabase secret key (sb_secret_) or service_role Never Server-only code
Stripe secret (sk_) or restricted (rk_) key Never Server-only code
RevenueCat secret key (sk_) Never Server-only code

Supabase. The API keys guide lists the publishable key as safe for a "mobile or desktop app" and says mobile apps are public components "where the key is bundled inside the compiled packages." Expo's Supabase guide adds that both public values are meant to ship, but "anyone who has them can query your database, so row level security is what keeps your data private." The secret key "bypasses every Row Level Security policy you have," and Supabase says never to put one in "a bundled mobile, desktop, or CLI package." The differences are covered in Supabase anon key vs service role key.

Firebase. Google's API keys page says Firebase API keys are "public by design" and only identify your project, with authorization "handled through Google Cloud IAM permissions, Firebase Security Rules, and Firebase App Check." One warning matters for AI features: if your app uses another API such as the Gemini Developer API, use a separate, restricted key, and "never include the Gemini Developer API in the allowlist for a publicly accessible API key." More in Is it safe to expose your Firebase API key?

Stripe. The Stripe keys page says the publishable key is for "Stripe.js, Elements, and mobile SDKs," "can't perform sensitive operations such as creating charges or reading account data," and can go in "applications you distribute." Secret and restricted keys are marked not safe to expose.

RevenueCat. RevenueCat's authentication docs say public SDK keys "are meant to make non-potent changes to subscribers, and must be used to configure the SDK." Secret keys, prefixed sk_, can delete subscribers and grant entitlements, so they "should be kept confidential and only stored on your own servers."

AI providers. OpenAI's key safety guide says: "Never deploy your key in client-side environments like browsers or mobile apps," because users can take it and "make requests on your behalf." Google's Gemini docs say "Do not hardcode API keys directly in web or mobile apps" and recommend a backend proxy. Anthropic's API key best practices say to keep the key confidential, the way you would a password. Both official TypeScript SDKs, openai-node and anthropic-sdk-typescript, disable browser use by default "to avoid exposing your secret API credentials" and say "React Native is not supported at this time." If generated code sets dangerouslyAllowBrowser: true or calls the provider's REST API directly from a screen, the key is on the device.

Put a server in between

The React Native docs describe the fix: build "an orchestration layer between your app and the resource," such as a serverless function that forwards the request with the key, because "secrets in server side code cannot be accessed by the API consumers the same way secrets in your app code can." Your app sends the user's session token. The server holds the provider key.

A proxy that forwards anything for anyone is just a public endpoint for your OpenAI account, so every version below does four things in order: verify the user, rate limit per user, validate the input, then call the provider with the key that never leaves the server.

Option 1: Expo Router API routes on EAS Hosting

Expo Router lets you write server endpoints in files ending in +api.ts. The API routes docs say they "can be used to handle sensitive data, such as API keys securely," and that a secret in a +api.ts file, plus everything it imports, "is not included in the client bundle." Server routes can read all environment variables, not just EXPO_PUBLIC_ ones. One condition: the stripping happens in expo/metro-config, so your metro.config.js must use it, and a secret imported by a normal screen file is bundled into the app.

This example verifies a Supabase user, applies a per-user limit with Upstash Ratelimit, and calls OpenAI. It type-checks against current packages.

// app/api/chat+api.ts runs on your server, never in the app bundle
import { createClient } from '@supabase/supabase-js';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Publishable values only. Used to verify the caller's token.
const supabase = createClient(
  process.env.EXPO_PUBLIC_SUPABASE_URL!,
  process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  { auth: { persistSession: false, autoRefreshToken: false } },
);

// Example limit: 20 requests per user per hour. Tune it for your app.
const limiter = new Ratelimit({
  redis: new Redis({
    url: process.env.UPSTASH_REDIS_REST_URL!,
    token: process.env.UPSTASH_REDIS_REST_TOKEN!,
  }),
  limiter: Ratelimit.slidingWindow(20, '1 h'),
  prefix: 'rl:chat',
});

const MAX_INPUT_CHARS = 2000; // example cap

export async function POST(request: Request) {
  // 1. Who is calling? Verify the Supabase access token, do not just decode it.
  const token = request.headers.get('Authorization')?.match(/^Bearer (.+)$/)?.[1];
  if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 });
  const { data, error } = await supabase.auth.getClaims(token);
  const userId = data?.claims?.sub;
  if (error || !userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  // 2. Per-user rate limit, in a store shared by every server instance
  const { success } = await limiter.limit(userId);
  if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 });

  // 3. Validate the input. The server picks the model and the output cap.
  const body = await request.json().catch(() => null);
  const message = typeof body?.message === 'string' ? body.message.trim() : '';
  if (!message || message.length > MAX_INPUT_CHARS) {
    return Response.json({ error: 'Bad request' }, { status: 400 });
  }

  // 4. Call the provider with a key that only exists on the server
  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      max_completion_tokens: 500,
      messages: [{ role: 'user', content: message }],
    }),
  });
  if (!upstream.ok) return Response.json({ error: 'AI request failed' }, { status: 502 });

  const result = await upstream.json();
  return Response.json({ reply: result.choices?.[0]?.message?.content ?? '' });
}

In the app, send the user's session token, never a provider key:

const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Sign in first');

const res = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${session.access_token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ message }),
});

Notes:

  • Relative URLs in native builds. The API routes docs say a native production build needs the server deployed and the origin set in the expo-router config plugin, so fetch('/api/chat') points at your server.
  • Where the key lives. OPENAI_API_KEY has no EXPO_PUBLIC_ prefix. The EAS Hosting env docs say server-side variables "are not inlined in the code" and are uploaded when you run eas deploy. They also say "only plain text and sensitive environment variables can be used" with EAS Hosting, so store the key as sensitive, not secret. Deployments are immutable, so re-deploy after changing a value.
  • We checked the split. In our test export, the API route landed in dist/server, and a server-only test key from .env appeared in none of the exported files, native or web.
  • No shared store, no limit. An in-memory counter resets on each server instance. EAS Hosting runs on the Cloudflare Workers runtime, which is why the example uses Upstash's HTTP client. More on limits in rate limit AI API routes.

Option 2: Supabase Edge Functions

If your app already uses Supabase, an Edge Function is the shortest path. The Securing Edge Functions guide recommends withSupabase({ auth: 'user' }), which requires a valid user JWT and gives you the verified claims. Your app usually calls it with supabase.functions.invoke, which sends the signed-in user's token.

// supabase/functions/ask-ai/index.ts
import { withSupabase } from 'npm:@supabase/server@1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    const userId = ctx.userClaims?.id
    if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 })
    // Add a per-user rate limit here, as in the route above.

    const body = await req.json().catch(() => null)
    const message = typeof body?.message === 'string' ? body.message.trim() : ''
    if (!message || message.length > 2000) {
      return Response.json({ error: 'Bad request' }, { status: 400 })
    }

    const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        max_completion_tokens: 500,
        messages: [{ role: 'user', content: message }],
      }),
    })
    if (!upstream.ok) return Response.json({ error: 'AI request failed' }, { status: 502 })

    const result = await upstream.json()
    return Response.json({ reply: result.choices?.[0]?.message?.content ?? '' })
  }),
}

Set OPENAI_API_KEY in the Dashboard or from a gitignored file with supabase secrets set --env-file, as the Edge Function secrets docs show. Keep verify_jwt on for this user-facing function. The pitfalls are covered in Supabase Edge Function auth.

Option 3: Firebase callable functions with App Check

On Firebase, a callable function receives the signed-in user in request.auth. Store the key with Cloud Secret Manager through defineSecret (config docs), and set enforceAppCheck: true so requests without a valid App Check token are rejected (App Check for Cloud Functions).

// functions/src/index.ts (Cloud Functions for Firebase, 2nd gen)
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';

const openaiKey = defineSecret('OPENAI_API_KEY'); // firebase functions:secrets:set OPENAI_API_KEY

export const askAI = onCall(
  { enforceAppCheck: true, secrets: [openaiKey] },
  async (request) => {
    if (!request.auth) throw new HttpsError('unauthenticated', 'Sign in first');

    const message = typeof request.data?.message === 'string' ? request.data.message.trim() : '';
    if (!message || message.length > 2000) {
      throw new HttpsError('invalid-argument', 'Message is empty or too long');
    }

    // Add a per-user limit here, keyed by request.auth.uid

    const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${openaiKey.value()}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        max_completion_tokens: 500,
        messages: [{ role: 'user', content: message }],
      }),
    });
    if (!upstream.ok) throw new HttpsError('unavailable', 'AI request failed');

    const result = await upstream.json();
    return { reply: result.choices?.[0]?.message?.content ?? '' };
  },
);

For Gemini specifically, Google offers a managed version of this pattern. The Firebase AI Logic docs say its proxy service and App Check integration "make sure that your Gemini API key stays on the server and is not embedded in your apps' codebase," and that it has configurable per-user rate limits. Its client SDKs are listed for Swift, Kotlin and Java, web JavaScript, Flutter, and Unity, not React Native, so check support for your stack before you plan around it.

Protect the proxy itself

Moving the key to a server stops key theft. It does not stop someone from calling your endpoint in a loop. That is the subject of stop strangers from running up your OpenAI bill, and the mobile version has the same layers:

  • Require sign-in and verify the token on the server, every request.
  • Rate limit per user in a shared store, with a daily budget for expensive calls. The numbers in our examples are examples.
  • Fix the model, max tokens, and input length on the server. Never let the app choose them.
  • Set a spend cap with the provider. OpenAI recommends monthly spend limits with hard enforcement, and notes that "spend alerts alone don't stop API traffic."
  • Add app attestation where you can. Apple's App Attest docs explain the reason: "You can't rely on your app's logic to perform security checks on itself because a compromised app can falsify the results." The Play Integrity API helps your backend check that requests come from "your genuine app, installed by Google Play, running on a genuine and certified Android device." Expo's AppIntegrity module wraps both, and your server must verify the result. On Firebase, App Check supports App Attest, DeviceCheck, and Play Integrity, and React Native Firebase notes that Play Integrity needs Play Store distribution to fetch tokens.

Attestation checks the app and device, not the person. Keep the sign-in and the per-user limit even after you add it.

Check your own app (safely)

Run these on your own project. They print file names and line numbers, never key values, and you do not need to paste a key anywhere.

  1. List every public variable. Read each name and ask whether it would be fine on a billboard.
git grep -n "EXPO_PUBLIC_"
  1. Search your code for secret key patterns and for SDK flags that only exist to allow client-side keys:
git grep -nE "sk-[A-Za-z0-9_-]{20,}|sk_live_|sk_test_|rk_live_|sb_secret_|service_role|dangerouslyAllowBrowser"
  1. Check the app config that will be embedded: npx expo config --type public.
  2. Inspect your own exported bundle. npx expo export writes the same kind of bundles your builds ship. The -a flag makes grep read the binary .hbc files, and -l prints only file names. In our blank test app this search found nothing; in your app, any hit needs a look.
npx expo export --platform ios --platform android
grep -rlaE "sk-[A-Za-z0-9_-]{20,}|sk_live_|sb_secret_|service_role" dist --exclude-dir=server
  1. Scan the repo too. If the code is on GitHub, the free VibeLocked scanner checks a public repo for these patterns, and the Live App Scanner checks the web version if you deploy one.

If a key already shipped in a store build

Treat it as leaked, even if the app has only a few users. Anyone who downloaded that build has the key, and it stays in every old install until the user updates or deletes the app. An over-the-air update or a new store release changes future copies, not the ones already downloaded or extracted.

  1. Revoke or rotate the key now at the provider. OpenAI says to "rotate your key immediately" if you believe it leaked. Supabase's rotation steps create a new secret key, swap it in everywhere, then delete the leaked one.
  2. Accept that the old build's feature will break. That is the price of the leak. Ship the server proxy first if you can do it within the hour; if not, revoke anyway.
  3. Check usage and billing for requests you did not make, and set spend limits.
  4. Ship the fixed build that calls your proxy, then clean the key out of git history.

The full sequence is in the first hour after an API key leaks, and the git cleanup is in remove an API key from GitHub history.

Checklist

  • No EXPO_PUBLIC_ variable holds an AI provider key, a Stripe secret or restricted key, or a Supabase secret or service_role key
  • No secrets in .env files read by react-native-config or react-native-dotenv, or in app config extra
  • npx expo config --type public shows nothing sensitive
  • A grep of your exported dist bundles (excluding dist/server) finds no secret key patterns
  • No dangerouslyAllowBrowser and no direct AI provider calls from app code
  • AI and payment calls go through an API route, Edge Function, or callable function that verifies the user
  • The proxy has a per-user rate limit in a shared store, fixed model and token caps, and input length limits
  • Provider spend limits are set with hard enforcement where available
  • Supabase RLS is on for every table; Firebase Security Rules and App Check are in place
  • App Attest, Play Integrity, or Firebase App Check is considered for expensive endpoints
  • EAS variables for server routes use sensitive visibility and no EXPO_PUBLIC_ prefix
  • Any key that ever shipped in a build has been rotated

Then run the full ship checklist for a vibe-coded app before you submit to the stores.

Ask your AI tool to audit your app

Paste this into Cursor, Claude Code, Windsurf, Bolt, or Replit Agent inside your project. It reads code only and asks the model not to print secret values.

Audit this Expo / React Native project for API keys and secrets that ship
inside the app.
1. List every EXPO_PUBLIC_ variable, every value read through react-native-config
   or react-native-dotenv, and every field in app config "extra". For each, say
   which service it belongs to and whether that key type is designed to be public
   (Supabase publishable/anon, Firebase config, Stripe publishable, RevenueCat
   public SDK key) or must stay on a server.
2. Flag any OpenAI, Anthropic, Gemini, or other AI provider key, any Stripe secret
   or restricted key, any Supabase secret or service_role key, any RevenueCat
   secret key, and any dangerouslyAllowBrowser: true in code that runs on the
   device. Give file and line.
3. List every network call from app code to a third-party API that needs a
   secret, and propose a server-side replacement (Expo Router +api.ts route,
   Supabase Edge Function, or Firebase callable function).
4. For each existing server route or function, check that it verifies the user's
   token, applies a per-user rate limit in a shared store, caps input size and
   output tokens, and fixes the model on the server.
5. Check that secrets are not imported by any file outside +api.ts routes and
   their imports, and that metro.config.js uses expo/metro-config.
6. Never print the value of any secret, key, or token. Report only file, line,
   and variable name.
Do not change any files yet. Give me a table of findings ordered by severity.

Check its answer yourself. Models sometimes call a key "safe because it is in .env," which is exactly the mistake this guide is about. More prompts are in the security review prompt guide.

Sources

Questions

Is it safe to put an API key in an EXPO_PUBLIC_ variable?

Only if the key is meant to be public, such as a Supabase publishable key, a Firebase config key, a Stripe publishable key, or a RevenueCat public SDK key. Expo replaces process.env.EXPO_PUBLIC_ references with the real value in your JavaScript bundle, and its docs say these values are visible in plain text in your compiled app. Never put an OpenAI, Anthropic, or Gemini key, a Stripe secret key, or a Supabase secret or service_role key there.

Does react-native-config or a .env file hide my API key?

No. The react-native-config README says the module does not obfuscate or encrypt secrets for packaging and tells you not to store sensitive keys in .env. The React Native security docs say anything in your app code can be read in plain text by anyone inspecting the bundle. A .env file only keeps the key out of your source code. It still ends up inside the build.

Does marking an EAS environment variable as secret protect it?

Only on Expo's servers. Secret visibility hides the value on the website, in EAS CLI, and in build logs. Expo's docs say it gives no extra security for values you embed in the app, and anything in client code should be considered public. If your JavaScript reads the variable, every user can read it too.

How do I call OpenAI from a React Native app safely?

Send the request to your own server endpoint, such as an Expo Router API route on EAS Hosting, a Supabase Edge Function, or a Firebase callable function. The server checks the signed-in user, applies a per-user rate limit, caps the input and output, and then calls OpenAI with a key that never leaves the server. OpenAI's own guidance says never to deploy your key in browsers or mobile apps.

My API key is already in an App Store or Play Store build. What now?

Rotate it at the provider right away. You cannot recall copies of an app that people already downloaded, and an update does not remove the key from old installs or from anyone who already extracted it. Revoke the key, move the call to a server, ship a new build, and check the provider's usage logs for spending you did not expect.