●FOUNDATION — The model layer underneath every AI app builder moved this week. Gemini 3.8 Flash reached general availability on September 2, and Claude Fable 5.1 arrived on September 1●IMPACT — Updates like these land without you choosing them. The quality of generated code can shift quietly from one day to the next, which is why it helps to keep your own record of when things changed●MAX — Rork Max generates native Swift across iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage. It compiles on a cloud Mac fleet, so you can build for Apple platforms without owning a Mac●DEPTH — Its reach into native capabilities is the real draw: AR and LiDAR scanning, Dynamic Island, Live Activities, HealthKit, NFC, and on-device machine learning through Core ML●PRICING — The Max plan runs $200 a month, with a free tier of roughly five prompts a week. For solo developers, working out how far the free tier gets you is a sensible first step●STACK — Standard Rork is built on React Native and Expo, aiming for a genuinely native experience rather than a web wrapper. Choosing between it and Max is a decision worth making deliberately●FOUNDATION — The model layer underneath every AI app builder moved this week. Gemini 3.8 Flash reached general availability on September 2, and Claude Fable 5.1 arrived on September 1●IMPACT — Updates like these land without you choosing them. The quality of generated code can shift quietly from one day to the next, which is why it helps to keep your own record of when things changed●MAX — Rork Max generates native Swift across iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage. It compiles on a cloud Mac fleet, so you can build for Apple platforms without owning a Mac●DEPTH — Its reach into native capabilities is the real draw: AR and LiDAR scanning, Dynamic Island, Live Activities, HealthKit, NFC, and on-device machine learning through Core ML●PRICING — The Max plan runs $200 a month, with a free tier of roughly five prompts a week. For solo developers, working out how far the free tier gets you is a sensible first step●STACK — Standard Rork is built on React Native and Expo, aiming for a genuinely native experience rather than a web wrapper. Choosing between it and Max is a decision worth making deliberately
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
I was running grep over a freshly exported JavaScript bundle during a pre-release check. What I was looking for was an analytics key I had prefixed with EXPO_PUBLIC_ myself.
It was there. That was expected, and still slightly unsettling, because I had set that variable's visibility on EAS to the most restricted level and had spent a while assuming that restricting it there also restricted what ended up inside the artifact.
The two settings decide entirely different things. The prefix decides whether a value is embedded in the shipped artifact. Visibility decides who is allowed to read it. Prefix governs the artifact, visibility governs the path. Since I started holding those apart, deciding where a key belongs stopped taking any real time.
The prefix and the visibility answer different questions
The first thing that stops adding up in Expo environment variables is treating these as one sliding "security level". Side by side, the mismatch is obvious.
Axis
What it decides
How it takes effect
The EXPO_PUBLIC_ prefix
Whether the value is embedded in client code
At bundle time, process.env.EXPO_PUBLIC_* is replaced inline with the value
EAS visibility (plaintext / sensitive / secret)
Who can read the value, and where
Changes what appears in the dashboard, in EAS CLI, and in job logs
So a variable set to secret still lands in the artifact if its name begins with EXPO_PUBLIC_ and client code reads it. Expo's own documentation says as much: on Environment variables in EAS, secrets "do not provide any additional security for values that you end up embedding in your application itself".
I had read that line before. I did not miss the words so much as get pulled along by the feel of the word "secret".
What secret actually stops is everything outside EAS
Secret visibility works on the paths that carry a value off EAS servers. It helps to name what closes and what stays open.
What closes: display in the dashboard, reads from EAS CLI, values printed into job logs, and eas env:pull writing the value down into a local .env file.
What stays open: the build job itself. Builds run on EAS servers, so a secret value is available there, and the bundler embeds it into client code exactly as it would any other value. Once embedded, it is part of what you ship.
"I don't want anyone reading this" and "I don't want this inside the app" sound alike and are different requirements. The first is answered by visibility. The second is answered by the prefix and by your architecture.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to separate the two axes — prefix and visibility — and decide where each key in your own app belongs without second-guessing
✦You will be able to catch, before release, the failure where a store build works fine but an OTA update ships with a blank value, which is painful to diagnose afterwards
✦You will be able to drop a bundle-search step and an EAS variable audit script straight into your own pre-ship checklist
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Stack the prefix and secret, and updates alone will blank the value
This is the part I am glad I found from the mechanism rather than from an outage. Marking an EXPO_PUBLIC_ variable as secret makes the result depend on which path built it.
On a build
EAS Build runs on EAS servers, so secret values reach the job. The bundle is produced with the value inlined, and the store build behaves the way you expect.
On an update
eas update is a different story. Secret variables are not readable outside EAS servers, so they are not used during the update process. process.env.EXPO_PUBLIC_ANALYTICS_KEY is never substituted and stays undefined.
How the symptom shows up
What trips you up in production is the timing: the gap appears only on one path. The app passes review, sits in the store, behaves normally for weeks, and then goes wrong the moment you push an OTA update.
// When substitution does not happen, this is undefined —// not the string "undefined", and not an empty stringconst key = process.env.EXPO_PUBLIC_ANALYTICS_KEY;// A common shape. Nothing throws here; only the destination quietly breaksanalytics.configure({ apiKey: key });
Your release note for that update probably says "copy fixes". No native code changed. Yet only the devices that received the update behave differently, which is an unpleasant shape of bug to chase.
The surrounding rules push in the same direction. EAS Update requires the --environment flag from SDK 55 onward, so pointing a build and an update at different environments is enough to change which values exist. It is the same lesson as leaving image out of eas.json: whatever you leave unspecified gets a default interpretation, and that interpretation does not consult your intentions.
You can check what shipped, with your own hands
Rather than reasoning about it, go look at the artifact. Three steps, under five minutes.
Load the same environment you build with (eas env:pull --environment production shows you what is readable and what is not)
Export a bundle with npx expo export
Search the output for a fragment of your own key
#!/usr/bin/env bash# ship-check.sh — confirm what actually got embedded before shipping# usage: ./ship-check.sh "sk_test_" "api.internal.example.com"set -euo pipefailOUT_DIR="dist"rm -rf "$OUT_DIR"npx expo export --platform ios --output-dir "$OUT_DIR" >/dev/nullFOUND=0for NEEDLE in "$@"; do # Covers both plain JS bundles and Hermes bytecode HITS=$(grep -rl --binary-files=text -F "$NEEDLE" "$OUT_DIR" 2>/dev/null || true) if [ -n "$HITS" ]; then echo "FOUND in artifact: $NEEDLE" echo "$HITS" | sed 's/^/ /' FOUND=1 else echo "not present: $NEEDLE" fidoneexit "$FOUND"
Hermes bytecode still keeps these strings in its string table, so --binary-files=text finds them. If a value shows up that should not be there, treat that key as public from this moment on.
A value that fails to show up is useful too. It narrows the cause to a missing prefix, a visibility set to secret, or a name mismatch in the code that reads it.
Sort keys into three boxes
I run several apps as an indie developer, and I only keep three places for keys. Fewer places to return to makes the decision faster.
Box
How it is stored
What goes in
Identifiers that may ship to the client
Prefixed, visibility plaintext or sensitive
AdMob ad unit IDs, a Sentry DSN, a public API base URL, a publishable analytics key
Values needed only at build time
No prefix, visibility sensitive or secret
Source map upload tokens, files such as google-services.json, store submission credentials
Values that must never reach a device
Not in EAS at all — kept behind your own server
Generative AI API keys, payment secret keys, database admin keys
Nothing in the third box is fixed by clever environment variable settings. Put one function of your own in front of it, and hand the device an access path instead of the key. That layer also lets you rotate credentials and rate-limit calls without shipping a new build.
When a value is hard to place, I ask one question: if a stranger held this, would I end up looking at an invoice? Anything that can generate a bill goes in the third box, and I would recommend keeping that rule absolute.
Let a script watch the sorting for you
Rules kept by human attention slip during a busy week. The EAS listing is available as JSON, so the contradiction check can be automated.
# --json implies --non-interactive. We only need names and visibility here,# so we never pull the values themselveseas env:list --environment production --json > env-production.json
// audit-env.js — find mismatches between prefix and visibility// run: node audit-env.js env-production.jsonconst fs = require('fs');const raw = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));const vars = Array.isArray(raw) ? raw : (raw.variables ?? []);// Name fragments that should never ride along to a device. Extend for your own stackconst SERVER_ONLY = /(SECRET|PRIVATE|SERVICE_ROLE|_TOKEN|ADMIN|WEBHOOK)/i;const problems = [];for (const v of vars) { const name = v.name ?? v.key; const visibility = String(v.visibility ?? '').toLowerCase(); const isPublic = name.startsWith('EXPO_PUBLIC_'); // (1) the combination that only breaks on updates if (isPublic && visibility === 'secret') { problems.push(`${name}: EXPO_PUBLIC_ plus secret. Builds embed it, eas update does not`); } // (2) a server-only name sitting on the public side if (isPublic && SERVER_ONLY.test(name)) { problems.push(`${name}: the name and the placement disagree. Move it behind your server`); } // (3) something meant to stay private left as plaintext if (!isPublic && SERVER_ONLY.test(name) && visibility === 'plaintext') { problems.push(`${name}: plaintext. Raise it to sensitive, or move it out of EAS`); }}if (problems.length > 0) { console.error('Environment variable placement is inconsistent:'); for (const p of problems) console.error(` - ${p}`); process.exit(1);}console.log(`checked ${vars.length} variables, no inconsistencies`);
The JSON shape varies across EAS CLI versions, so the script accepts both a bare array and a variables key. Look at your own output once by eye before you trust it.
I keep this at the entrance of the release workflow. Failing before a build starts is far cheaper than noticing after review has passed.
The first move on generated code
When you start an app with an AI builder like Rork, third-party keys enter the conversation naturally. You paste a key into a prompt, the generated code drops it straight into a fetch header, and because something working appears so quickly, the placement decision is the one thing that gets deferred.
Two moves come first. Rotate any key you pasted into a prompt as soon as you have confirmed the flow works. Then prefix everything that legitimately stays on the client, and move everything else behind a function of your own.
// app/config/env.js — verify at startup that the values you expect exist// Never crash production; make it visible in development and internal buildsconst REQUIRED = ['EXPO_PUBLIC_API_BASE_URL', 'EXPO_PUBLIC_ANALYTICS_KEY'];export function assertPublicEnv() { const missing = REQUIRED.filter((name) => !process.env[name]); if (missing.length === 0) return; const message = `public env vars were not substituted: ${missing.join(', ')}`; if (__DEV__) { throw new Error(message); // stop here while developing } // In production, record rather than crash. // This single line is what surfaces an OTA-only gap the same day reportIssue('public_env_missing', { missing, channel: releaseChannelName() });}
Production does not crash here because the gap has nothing to do with what the person using the app did. Recording it and letting only me notice keeps their day intact.
An embedded value cannot be taken back
This is the part I see overlooked most often. A value that made it into an artifact cannot be recalled by shipping an update. Every device already holding that build keeps holding that value.
So the response is not "replace" but "revoke". Issue a new credential, accept both for a while on the server side, wait until the newer build has spread, then retire the old one. Watching adoption while you proceed is the same rhythm as a phased release strategy for Rork apps.
Early on I thought mostly about hiding keys. That did not serve me well. What I keep now is a different line: assume nothing stays hidden, hand over only what is safe to hand over, and make sure anything handed over can be revoked.
What to do first
Add one search step to your pre-ship routine. Pass a fragment of your own key to ship-check.sh and run it against the build you are about to submit.
If something unexpected comes back, that is a key you can still revoke today. It costs far less than one already sitting in a build people have installed.
I am still checking these placements one at a time myself, and I would be glad if this saves someone else the same pause.
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.