◉RORK LABJP
●EXPO — EAS Observe now records native crashes too (Oct 7). Check on SDK 57 at 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) were added to the model menu●RN 0.88 — Not out yet. The latest is rc.4 (Oct 7), and 0.87.1 is the current Latest●Q&A — A write-up lists four snags on the way to TestFlight with an Expo + Supabase app●ECOSYSTEM — With Shopify going back to native, one write-up explains why a solo developer stays on Expo●NEW — Before you write 'ship in a week', look at the breakdown of waiting days●EXPO — EAS Observe now records native crashes too (Oct 7). Check on SDK 57 at 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) were added to the model menu●RN 0.88 — Not out yet. The latest is rc.4 (Oct 7), and 0.87.1 is the current Latest●Q&A — A write-up lists four snags on the way to TestFlight with an Expo + Supabase app●ECOSYSTEM — With Shopify going back to native, one write-up explains why a solo developer stays on Expo●NEW — Before you write 'ship in a week', look at the breakdown of waiting days
Articles/Dev Tools
⬡ Dev Tools/2026-10-13Intermediate

Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle

A key can be missing from your source and still be sitting inside the finished app. I ran expo export on Expo SDK 57, searched the bundle with grep and a short Node script, and recorded which secrets show up and which don't.

Expo214Rork579Security9EXPO_PUBLICEnvironment VariablesPre-release CheckHermes9

The .env file is in .gitignore, and the repository holds no keys. As an indie developer, that's usually where I stop and tell myself it's fine. But what reaches your users isn't your source. It's the bundle.

So I made an empty Expo project, planted dummy keys on purpose, ran expo export, and went looking through the finished file by hand. The only thing I changed was where I checked: the bundle instead of the source.

What I'd like to say up front is this: check for keys in the exported bundle, not in the source. Below is the procedure, along with what actually happened when I ran it.

What prompted this was a write-up on Zenn (in Japanese) from a developer who reworked how their app calls an image-generation API before release. Hiding the key wasn't enough on its own, and the question "what if someone skips the app and calls the endpoint directly?" applies to my own apps too.

The setup

I used a throwaway project. Every value is a dummy; no real key was involved.

ItemDetail
Date2026-10-13
Projectcreate-expo-app blank template (expo 57.0.28, react-native 0.86.3)
Exportnpx expo export --platform android (emits a Hermes bytecode .hbc file)
Node22

I put three lines in .env:

EXPO_PUBLIC_API_BASE=https://api.example.com
EXPO_PUBLIC_OPENAI_KEY=sk-proj-DUMMY0123456789abcdefABCDEF0123456789
SERVER_ONLY_TOKEN=whsec_DUMMYSERVERONLY0123456789

App.js reads process.env.EXPO_PUBLIC_API_BASE and process.env.EXPO_PUBLIC_OPENAI_KEY, contains a Google-API-key-looking string typed directly into the source, and reads process.env.SERVER_ONLY_TOKEN. In other words, three things you shouldn't do, side by side.

Step 1: Export something close to what ships

npx expo export --platform android --output-dir dist

The output ends with dist/_expo/static/js/android/index-….hbc. That file is the JavaScript that goes inside the app. Expo's environment-variable docs say that EXPO_PUBLIC_ variables "will be visible in plain-text in your compiled application." Let's go and see that for ourselves.

Step 2: Start with grep (two traps)

grep -a -o -E 'sk-[A-Za-z0-9_-]{20,}' dist/_expo/static/js/android/*.hbc

On my machine this printed one line:

sk-proj-DUMMY0123456789abcdefABCDEF0123456789Invalid

Two things stood out.

First, that trailing Invalid. A neighboring string is glued onto the end of the key. The string table in the bytecode has no separators, so the regex keeps extending as long as the characters fit. Don't treat a match as the exact boundary of the key.

Second, the number you get from grep -c. Running grep -c -a 'sk-proj' on the same file returned 1, but that's the number of matching lines, not occurrences. The binary is effectively one line, so you get 1 however many times the string appears. If you want a count, use -o and count the output.

Step 3: A script that searches by shape and by value

Typing out key formats from memory every time gets old, so I combined two checks into one script:

  • By shape: pick up strings that look like well-known key formats.
  • By value: check whether any value from .env appears verbatim in the bundle.
// scan-bundle.mjs — flags secret-looking strings and raw .env values left in an exported Expo bundle
// usage: node scan-bundle.mjs <export-dir> [.env path] [--allow=NAME,NAME]
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
 
const args = process.argv.slice(2);
const allowArg = args.find((a) => a.startsWith('--allow='));
const allow = new Set((allowArg ?? '').replace('--allow=', '').split(',').filter(Boolean));
const [dir, envPath = '.env'] = args.filter((a) => !a.startsWith('--'));
if (!dir || !existsSync(dir)) {
  console.error('usage: node scan-bundle.mjs <export-dir> [.env]');
  process.exit(2);
}
 
// 1) By shape: common key formats (false positives are fine; a human decides)
const SHAPES = [
  ['OpenAI-style', /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g],
  ['Google API key', /AIza[0-9A-Za-z_-]{35}/g],
  ['Stripe secret', /sk_(?:live|test)_[0-9A-Za-z]{16,}/g],
  ['Webhook secret', /whsec_[0-9A-Za-z]{16,}/g],
  ['AWS access key', /AKIA[0-9A-Z]{16}/g],
  ['JWT-like string', /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
];
 
// 2) By value: does any .env value appear verbatim in the bundle?
const envValues = existsSync(envPath)
  ? readFileSync(envPath, 'utf8').split('\n')
      .map((l) => l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/))
      .filter(Boolean)
      .map(([, k, v]) => [k, v.replace(/^["']|["']$/g, '')])
      .filter(([k, v]) => v.length >= 8 && !allow.has(k))
  : [];
 
const mask = (s) => (s.length <= 10 ? '***' : s.slice(0, 6) + '…' + s.slice(-2));
 
function* walk(d) {
  for (const n of readdirSync(d)) {
    const p = join(d, n);
    statSync(p).isDirectory() ? yield* walk(p) : yield p;
  }
}
 
let hits = 0;
for (const f of walk(dir)) {
  if (!['.hbc', '.js', '.map', '.bundle'].includes(extname(f))) continue;
  const text = readFileSync(f).toString('latin1'); // Hermes bytecode still stores string literals as plain text
  for (const [label, re] of SHAPES) {
    for (const m of text.matchAll(re)) {
      hits++;
      console.log(`SHAPE  ${label.padEnd(16)} ${mask(m[0])}  @${m.index}  ${f}`);
    }
  }
  for (const [k, v] of envValues) {
    const i = text.indexOf(v);
    if (i >= 0) {
      hits++;
      console.log(`VALUE  ${k.padEnd(28)} ${mask(v)}  @${i}  ${f}`);
    }
  }
}
console.log(hits ? `\n${hits} hit(s). Investigate any VALUE you did not allow on purpose.` : '\n0 hits');
process.exit(hits ? 1 : 0);

It exits with 1 if anything is found and 0 if not, so it can sit right after the build as a gate.

Results: what was found and what wasn't

I ran it against the first build, the one with all three bad habits:

node scan-bundle.mjs dist .env
SHAPE  OpenAI-style      sk-pro…89  @131303  …/index-….hbc
SHAPE  Google API key    AIzaSy…qr  @140062  …/index-….hbc
VALUE  EXPO_PUBLIC_API_BASE         https:…om  @150635  …/index-….hbc
VALUE  EXPO_PUBLIC_OPENAI_KEY       sk-pro…89  @131303  …/index-….hbc
 
4 hit(s). Investigate any VALUE you did not allow on purpose.
How it was placedValue in the bundle?How it was found
EXPO_PUBLIC_OPENAI_KEY (read in code)YesShape and value
Google-key-looking string typed into the sourceYesShape only (it isn't in .env, so a value search can't see it)
EXPO_PUBLIC_API_BASE (a URL)YesValue (public by nature, so it belongs on the allow list)
SERVER_ONLY_TOKEN (no EXPO_PUBLIC_ prefix)No— (only the variable's name survived; the value was not found)

Two results differed from what I'd have guessed before running it.

An EXPO_PUBLIC_ variable you don't use is not embedded, even if it's in .env. For the second build I removed the code that read the key, left .env untouched, and exported again. The key's value was gone from the bundle. Only variables you actually reference as process.env.EXPO_PUBLIC_… in code get inlined. So the better question isn't "what's in my .env?" but "which variables does my code read?"

A variable without the EXPO_PUBLIC_ prefix is empty inside the app. process.env.SERVER_ONLY_TOKEN has no value at runtime, and only its name stayed in the bundle. That's good news for leakage and bad news for usability: a value that only the server may hold has to live on the server.

Re-scanning after the fix

I moved the call that needs the key to the server. The app now sends only a short kind of request (for example icon):

import { Text, View } from 'react-native';
 
const API_BASE = process.env.EXPO_PUBLIC_API_BASE;
 
async function ask(kind) {
  // The key lives on the server. The app only sends a choice from a fixed list.
  const res = await fetch(`${API_BASE}/generate`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ kind }),
  });
  return res.json();
}
 
export default function App() {
  return (
    <View style={{ flex: 1, justifyContent: 'center' }}>
      <Text onPress={() => ask('icon')}>{API_BASE}</Text>
    </View>
  );
}

Export again and scan, allowing only the URL that is meant to be public:

rm -rf dist && npx expo export --platform android --output-dir dist
node scan-bundle.mjs dist .env --allow=EXPO_PUBLIC_API_BASE
# → 0 hits (exit code 0)

Drop the allow list and exactly one hit, EXPO_PUBLIC_API_BASE, comes back. Naming the values that are allowed to appear makes it easy to notice when someone later adds a variable that isn't.

The design of keeping keys off the client has its own articles: a thin relay on Workers for the proxy, and the secret boundary and rotation runbook for swapping keys. This article is the other side: checking that the design actually held in a real build.

Limits, and what I did not check

  • Shape matching misses keys in unusual formats. The value check (comparing against .env) fills part of that gap, but it can't help with values that aren't in .env.
  • I only exported for Android. I haven't checked an iOS export, or the final .aab / .ipa that EAS Build produces.
  • If you publish source maps, include the .map files in the same scan. The script walks .map files too.
  • "No key-shaped string in one file" doesn't prove the app is safe. A scan reduces what slips through; it doesn't replace a design where the key was never on the client.

A key isn't something to hide in the app; it's something that never goes into the app. The scan only confirms that the line is holding.

Before your next release build, run expo export once and just look at this script's exit code. I plan to run the same one-liner on the next update of one of my own apps.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.
⬡ Dev Tools2026-09-21
A build that stops with exit code 0 — the flag that silenced the log, and the check that let an empty file through
Two reports landed in the same week: eas build --local stopping with exit code 0 but produced no further output, and a zero-byte stub that never got embedded. Both came down to settings that reduce output. Here is how to audit your own scripts.
⬡ Dev Tools2026-09-07
Adding .easignore stops EAS from reading .gitignore — count what actually ships to the build
EAS Build decides what to upload from .gitignore, and the moment you add .easignore the two swap places. Here is how I count the bundled files before sending, plus a measured result: once a parent directory is excluded, an exclamation mark cannot bring a file back.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links