If you have been pair-programming a React Native or Expo app inside Windsurf AI and the agent keeps reaching for .env.local whenever you ask for an API key, this article is for you. The behavior is consistent across vibe-coded mobile projects and almost always feels right until the day you run eas build --profile production and either the value is missing, or worse, ends up readable inside the shipped binary.
Short answer
A .env.local file is a local development convenience. It works when you run your app through npx expo start from your laptop because the Expo CLI reads the file from disk and inlines values prefixed with EXPO_PUBLIC_ into the Metro bundle. On a mobile production build, the picture changes: Expo's environment variables guide is clear that any EXPO_PUBLIC_ value becomes plain text inside the compiled application, and the EAS environment variables documentation confirms that .env.local is excluded from version control and so never reaches a remote EAS Build job. Windsurf AI does not know any of this unless you tell it.
What you should know
- Mobile bundles are public. Anything inlined into the JavaScript bundle by Metro ends up inside the IPA or AAB and is readable with
unzipplus a text editor. .env.localis gitignored by default. That is the right choice for laptops, and the reason EAS Build cannot use it. Remote builds need EAS environment variables instead.- Only
EXPO_PUBLIC_is inlined. Variables without the prefix stay on the Node side of the Expo CLI and are not packaged for the device. - Bare React Native is stricter. Libraries like
react-native-configship every value to the device once the variable is imported in a screen file, regardless of naming. - Windsurf reproduces web patterns. Most code patterns the agent has seen for
.env.localcome from Next.js and Vite, where the file is a true server-side runtime source.
Why does Windsurf AI default to .env.local for a mobile project?
The short answer is training-data inertia. Windsurf, Cursor, and similar agents pick up the .env.local idiom from the dense corpus of Next.js, Remix, and Vite tutorials, where the file is a normal runtime input that stays on the server. When you ask Windsurf for a Stripe key or an OpenAI key inside a React Native screen, the agent reaches for the same idiom and writes process.env.MY_API_KEY plus a .env.local entry.
In practice the generated code passes a quick smoke test. You hit save, Expo refreshes, the call succeeds, and Windsurf moves on. The failure mode only appears later: either at eas build time, when the variable is undefined and the bundle ships with undefined in place of the value, or after a successful production build, when the key is sitting in the JavaScript chunk for anyone with the AAB to read.
The agent is not malicious here, and it is not exactly wrong. It is treating a mobile project like a Node.js process. A reader who only inspects the diff will not see the gap. This is why a passive review of AI-generated mobile code rarely catches the issue.
How does Expo actually load .env files at build time?
The short answer is that the Expo CLI reads them, not the device. On npx expo start or eas build, the Expo CLI walks the standard dotenv priority chain (.env.local, .env.[mode].local, .env.[mode], .env) and exposes any EXPO_PUBLIC_ prefixed value to Metro. Metro then performs a literal substitution: process.env.EXPO_PUBLIC_API_URL in your source becomes "https://api.example.com" in the output chunk.
The Expo documentation states the security implication directly: do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables, because these variables will be visible in plain text in your compiled application. A reader who downloads the AAB or IPA from the Play Store or App Store can unzip it, locate the bundled JavaScript, and read every inlined value. That matches the OWASP MASTG guidance on data storage, which lists hardcoded secrets in application binaries as a recurring weakness category for both iOS and Android.
The practical consequence: a .env.local value that does not start with EXPO_PUBLIC_ is invisible to the app at runtime, and a value that does start with EXPO_PUBLIC_ is, for security purposes, the same as putting the string inside App.tsx.
What happens when EAS Build runs without .env.local?
The short answer is that the remote build never sees the file. EAS Build runs on Expo's infrastructure, pulls your repository (or your uploaded archive), and starts a fresh checkout. Because .env.local is in .gitignore by convention, the file is not in the archive at all, and the production bundle compiles with process.env.EXPO_PUBLIC_API_URL === undefined for any value you only kept locally.
For production builds you have three supported paths:
- Define the variable with
eas env:create --name EXPO_PUBLIC_API_URL --value https://api.example.com --environment production --visibility plaintext. The variable is fetched at build time and inlined the same way it would be on your laptop. - Keep the value out of the client entirely and call a server endpoint you control. Anything that needs to stay private (Stripe Secret Key, Twilio auth token, server-to-server tokens) lives on the server and the mobile app uses an authenticated session.
- Upload a build-time
.envfile as part of a custom workflow. This is rare in practice; teams usually adopt path 1.
The key habit shift for a Windsurf-driven workflow: when you accept generated code that references process.env.SOMETHING, immediately ask whether that value belongs on the client at all, and if so, run eas env:list to confirm it exists in the production environment.
How do .env.local and EAS environment variables compare?
The short answer is that they cover different stages of the pipeline. Treat .env.local as a laptop-only file and EAS variables as the source of truth for any non-laptop build.
| Concern | .env.local on disk | EAS environment variable |
|---|---|---|
| Where it lives | Your machine, gitignored | Expo servers, scoped to development, preview, or production |
Read by expo start | Yes | Yes (after eas env:pull) |
Read by eas build (remote) | No | Yes |
| Visible to teammates | No | Yes (depending on visibility) |
| Visibility levels | Plain file | Plain text, Sensitive, Secret |
| Can hold a real secret | Only on your machine | Only if visibility is Secret and you do not inline it client-side |
| Final destination if inlined | None (file is local) | The compiled IPA or AAB |
A disciplined mobile project usually combines both: .env.local for the keys a single developer needs while testing a branch, EAS environment variables for everything the production build references, and a server-side store (Supabase Vault, AWS Secrets Manager, GCP Secret Manager) for anything that must stay out of the client binary. PTKD.com (https://ptkd.com) sees this exact split repeatedly when scanning AI-generated AAB and IPA builds: the AAB usually contains exactly the keys that the team thought were local-only because the agent inlined them at build time.
What does a safe pattern look like for a Windsurf-generated screen?
The short answer is: anything imported by a component file is public, and you should code as if a stranger will read it. A safe pattern for a Windsurf-generated mobile feature looks like this:
- The mobile app holds public, non-sensitive values only: the API base URL, the Supabase anon key (paired with Row Level Security), a Sentry public DSN, a Firebase web config.
- All real secrets sit behind an authenticated endpoint. The mobile app sends an access token, and the server uses its own credentials (Stripe Secret Key, OpenAI key) to call the third-party API.
- The repository keeps a
.env.examplefile with empty values and inline comments explaining which prefix is acceptable. This file is what Windsurf reads when it suggests new variables, so a well-curated example file biases the agent toward the right pattern.
This is the same principle Supabase describes in its API keys and security documentation, where the anon key is designed for client distribution and only becomes dangerous when RLS is not configured. The mobile app can hold the anon key; it must never hold the service role key.
What to watch out for
Three failure modes show up repeatedly in vibe-coded mobile projects:
- A non-public variable read by a screen. Windsurf writes
process.env.API_SECRETwithout theEXPO_PUBLIC_prefix. The app runs in dev because the value is in your shell environment, then ships to production withundefined. The fix is to rename the variable so the prefix matches the intent, and to keep secrets out of any file imported from a screen. react-native-configin a bare project. The library has no prefix gate; once a screen file importsConfig.STRIPE_SECRET_KEY, the build inlines that value into the bundle. The react-native-config README is honest about this: the README notes that config variables are visible in the resulting binary and that they should not be used for secrets.- Production AAB ships with a forgotten test key. The team rotated the production key in EAS but never deleted the older
.env.localentry, and a Windsurf refactor reintroduced the laptop value. A pre-submission scan of the AAB catches this; reading the diff does not.
None of these is unique to Windsurf. The pattern repeats with Cursor, Cline, and Google Antigravity. The root cause is the agents' assumption that .env.local works the same way on mobile as on the web.
Key takeaways
- Treat
.env.localas a laptop file only. EAS Build cannot see it, and shipping anything sensitive in it is a path toward an inlined production secret. - Anything prefixed with
EXPO_PUBLIC_is public the moment your IPA or AAB reaches the store. Plan accordingly: server-side checks, Supabase RLS, Firebase Security Rules. - Use
eas env:createagainst theproductionenvironment for build-time values, and a server-side store for true secrets that must never sit on the device. - Curate a
.env.examplefile as guidance for the agent. Windsurf will mirror whichever pattern it sees in the repository. - For teams shipping AI-generated mobile builds, an external scan of the compiled APK, AAB, or IPA catches the inlined keys that a code review misses. Platforms like PTKD.com (https://ptkd.com) are built around exactly that pre-submission read of the artifact, aligned with OWASP MASVS storage categories.



