●ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directly●CORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App Clips●SEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z Speedrun●M&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026●GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029●ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directly●CORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App Clips●SEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z Speedrun●M&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026●GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029
Every Key You Ship Is Public: Secret Boundaries and Rotation for Rork-Generated Apps
Unzip your own .ipa, run strings, and your environment variables are right there in plain text. Here is how I sort keys into three tiers, move the dangerous ones behind an edge proxy, and keep a rotation runbook that assumes leakage.
I unzipped one of my own .ipa files late one evening and piped the binary through strings.
There it was: the value I had named EXPO_PUBLIC_ANALYTICS_KEY, sitting in the output as plain text. No obfuscation, no encoding. From build artifact to extracted key took about ten seconds.
I knew this intellectually. Seeing my own binary read my own key back to me was a different experience entirely.
Running six apps solo means the key count reaches double digits fast. Ads, subscriptions, analytics, crash reporting, translation APIs. When you let a generator write the scaffolding, all of that initialization code appears at remarkable speed. The one decision it cannot make for you is where each key belongs.
What follows is how I put that decision into words, then applied it across all six apps.
A key on the client is not encrypted. It is published.
Start from the premise. The moment an app bundle lands on a user's device, it is readable.
In Expo, keys live as strings inside the JavaScript bundle. In Swift, they live in the binary's string table. Obfuscation raises the cost of extraction; it does not create a boundary.
The fastest way to internalize this is to check your own artifact.
# iOS: an .ipa is a zip. Unpack it and run strings on the binary.unzip -q MyApp.ipa -d /tmp/ipastrings /tmp/ipa/Payload/MyApp.app/MyApp | grep -iE 'key|secret|token' | head -20# Expo: grep the JS bundle directlygrep -oE 'EXPO_PUBLIC_[A-Z_]+"[^"]*"' /tmp/ipa/Payload/MyApp.app/main.jsbundle | head -20# Android: the same trick works on an apkunzip -q app-release.apk -d /tmp/apkstrings /tmp/apk/classes.dex | grep -iE 'AIza|sk_live|pk_live' | head
Three commands tell you exactly what your app publishes. I have made this a ritual before any new app ships.
Everything those commands surface is public information. You do not hide it. You design so that publishing it costs you nothing.
Sort every key into one of three tiers
Thinking in terms of "secret or not" leads to paralysis. Three tiers make the placement decision fall out automatically.
Tier
Nature
Examples
Where it lives
Cost if exposed
Tier 1: public identifiers
Never secret to begin with
AdMob app and unit IDs, RevenueCat public SDK key, Sentry DSN
Hardcoded in the client is fine
Effectively none; abuse is blocked at other layers
Tier 2: restricted public keys
Meant to be visible, but only with constraints
Firebase web API key, Maps API key, write-only analytics keys
Client is acceptable, provided the issuer restricts bundle ID, referrer, and scope
Metered-billing abuse, but only when restrictions are missing
Tier 3: real secrets
Whoever holds it can act as your server
OpenAI or Gemini API keys, Stripe secret key, RevenueCat secret API key, Supabase service_role key
Server or edge function only. Never the client.
Direct charges on your card; full read and write on your data
When a key resists classification, ask one question: could a stranger holding this key spend my money or read all of my users' rows? If yes, it is tier 3, and prefixing it with EXPO_PUBLIC_ loses the game before it starts.
The most commonly missed part of tier 2 is the restriction itself. A Google Cloud key without an application restriction (Android package name or iOS bundle ID) and an API restriction (an explicit list of endpoints it may call) is a tier-3 key wearing a tier-2 costume.
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
✦A three-command audit that extracts secrets from your own shipped binary, and the three-tier classification it forces
✦A working edge proxy that hides tier-3 keys and caps the financial blast radius with rate limits and body caps
✦A rotation runbook built on the assumption that keys leak, plus a CI gate that stops the same mistake twice
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.
In Expo, the name of a variable determines its behavior.
Anything prefixed with EXPO_PUBLIC_ is inlined into the bundle at build time. It is not read from the environment at runtime. The expression process.env.EXPO_PUBLIC_FOO in your source is replaced by a string literal.
// what you wroteconst key = process.env.EXPO_PUBLIC_ANALYTICS_KEY;// what ends up in the bundleconst key = "a1b2c3d4e5f6...";
So adding .env to .gitignore changes nothing. Storing the value in EAS Secrets changes nothing. The key stays out of Git and lands on every user's device.
Variables without the prefix never reach the bundle, which also means client code cannot read them. That is not a limitation. That is the boundary working. Things you cannot read on the client get used where they can be read: on a server.
The first command I run against any generated codebase is this one.
Then I review each surviving line and ask whether it is genuinely tier 1 or tier 2. Running this across six apps for the first time surfaced three tier-3 keys, all belonging to translation and generative-AI calls.
Move tier-3 keys behind an edge proxy
Relocating a tier-3 key does not require you to run a server. An edge function is enough. On Cloudflare Workers, this runs as written.
// worker.js — a thin proxy in front of a generative AI API.// The key exists only as a Workers secret (env.UPSTREAM_API_KEY).const MAX_BODY_BYTES = 8 * 1024;export default { async fetch(request, env) { if (request.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } // 1. Confirm the caller is a genuine install (App Attest / Play Integrity) const attestation = request.headers.get("X-App-Attestation"); if (!(await verifyAttestation(attestation, env))) { return new Response("Unauthorized", { status: 401 }); } // 2. Per-device rate limit: a counter in KV with a 60-second window const deviceId = request.headers.get("X-Device-Id") ?? "unknown"; const bucket = `rl:${deviceId}:${Math.floor(Date.now() / 60000)}`; const count = Number((await env.RATE_KV.get(bucket)) ?? 0); if (count >= 20) { return new Response("Too Many Requests", { status: 429 }); } await env.RATE_KV.put(bucket, String(count + 1), { expirationTtl: 90 }); // 3. Cap the input size — the cheapest brake on runaway billing const body = await request.text(); if (body.length > MAX_BODY_BYTES) { return new Response("Payload Too Large", { status: 413 }); } // 4. Attach the key here, and only here const upstream = await fetch("https://api.example-ai.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.UPSTREAM_API_KEY}`, }, body, }); // 5. Do not echo upstream headers back to the client return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": "application/json" }, }); },};async function verifyAttestation(token, env) { if (!token) return false; // Swap this for a real App Attest / Play Integrity verification with caching const cached = await env.RATE_KV.get(`att:${token}`); return cached === "ok";}
Why this shape, and not a two-line pass-through?
Because a proxy is not only a place to hide a key. It is the place where you decide the maximum size of the damage.
A pass-through proxy hides the key and exposes the URL instead. An open endpoint anyone can hammer is wired straight to your invoice. That is why the rate limit (step 2), the body cap (step 3), and the attestation check (step 1) are present from day one. Twenty requests per device per minute has never once interfered with legitimate use in my apps.
A rotation runbook that assumes the key already leaked
Boundaries do not prevent leaks. Accidental commits, screenshots, log lines, a contractor's old laptop. Replace "this key will not leak" with "how many minutes until a leaked key is harmless?"
Each app has a docs/ runbook with these six steps. Once practiced, a single key takes 15 to 25 minutes.
Stop the bleeding (0–5 min). In the issuer's console, tighten restrictions rather than deleting the key. Deleting immediately breaks every live user and destroys your ability to diagnose. Narrow the IP range, bundle ID, and callable APIs to the minimum instead.
Establish the blast radius (5–10 min). Read the last 24 hours of usage in the issuer's dashboard: which callers, any spikes. For a billing key, lower the spend alert threshold right now.
Issue the replacement (10–13 min). Create the new key before deleting the old one. An overlap window where both are valid is the only way to rotate without downtime.
Distribute (13–20 min). For a tier-3 key, you swap a secret in the edge function and you are done. No rebuild, no App Store review. This is the entire practical payoff of having moved tier 3 off the client. A tier-2 key embedded in the client needs an OTA update or a resubmission — hours to days.
Revoke the old key (20+ min). Confirm in logs that traffic is succeeding on the new key, then delete the old one. Keep the overlap window under 24 hours.
Record it. One line: date, cause, tier, elapsed time. That line is what tells you next quarter which key should move behind the proxy.
Step 4 is the point. A key's tier determines its rotation speed. Keep tier 3 behind a proxy and an emergency is a secret swap. Leave the same key in the client and it stays live and leaked for the entire duration of a review queue.
Once that asymmetry became clear, I stopped treating client placement as a security question and started treating it as a recovery-time question.
What generated code does repeatedly, and how CI stops it
Generators like Rork optimize for code that runs. Calling an API directly from the client is the shortest path to running code, so that is what tends to appear. I do not think of this as a flaw in the tool. It is a decision the tool leaves to me, and I should be ready to catch it.
Across six apps, the contamination collapsed into three patterns.
Prefix inflation. A variable does not resolve, so someone adds EXPO_PUBLIC_ to make the error go away. The error goes away. The key becomes public.
Sample-value drift. Placeholders survive into production, or worse, a real key survives into the README as a "sample."
Log leakage.console.log(config) dumps the whole configuration object, and it ships in the release build.
Human review misses all three. This script runs in my CI pre-push hook.
#!/usr/bin/env bash# scripts/secret_boundary_gate.sh — fail if tier-3 material reaches client codeset -euo pipefailFAIL=0# 1. Real tier-3 key formats must never appear in client source.# Keep placeholders uniform (YOUR_API_KEY) so this stays signal, not noise.if grep -rnE 'sk_live_[A-Za-z0-9]{8,}|sk-[A-Za-z0-9]{20,}|service_role' app/ src/ 2>/dev/null; then echo "FAIL: tier-3 key material found in client code" FAIL=1fi# 2. Allowlist, not blocklist, for EXPO_PUBLIC_ variablesALLOWED='EXPO_PUBLIC_API_URL|EXPO_PUBLIC_ENV|EXPO_PUBLIC_SENTRY_DSN|EXPO_PUBLIC_REVENUECAT_PUBLIC_KEY'if grep -rhoE 'EXPO_PUBLIC_[A-Z0-9_]+' app/ src/ 2>/dev/null \ | sort -u | grep -vE "^(${ALLOWED})$" | grep .; then echo "FAIL: EXPO_PUBLIC_ variable not on the allowlist. Confirm tier 1 or 2, then add it." FAIL=1fi# 3. Whole-config loggingif grep -rnE 'console\.log\((config|env|process\.env)\)' app/ src/ 2>/dev/null; then echo "FAIL: configuration object logged verbatim" FAIL=1fi[ "$FAIL" -eq 0 ] && echo "OK: secret boundary clean"exit "$FAIL"
An allowlist rather than a blocklist, because a blocklist is always one leak behind. When a new key appears, CI fails and asks the developer a question: is this tier 1 or tier 2? That half-second pause is the boundary.
In three months the gate has fired four times. All four were me, adding the prefix by reflex.
Pick one app you have already shipped. Unzip the .ipa or .apk and run strings. It takes ten seconds.
Map whatever appears onto the three-tier table. If even one tier-3 key shows up, write its rotation runbook before you write the proxy. Reverse that order and you will have nowhere to retreat to when production breaks mid-migration.
Working through all six of my apps took three weeks. Moving one app at a time, with generous overlap windows, turned out to be faster than trying to cut over at once.
Drawing boundaries is unglamorous work that never earns a line in a release note. Still, being able to open my own binary and understand exactly what it reveals changed how I think about running these apps at all. Thank you for reading.
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.