AI-coded apps

    Does Windsurf AI .env.local work for a mobile production build?

    A Windsurf editor window showing a React Native project with a .env.local file open next to a production EAS Build dashboard, illustrating the gap between local development variables and a compiled mobile production binary

    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 unzip plus a text editor.
    • .env.local is 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-config ship 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.local come 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:

    1. 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.
    2. 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.
    3. Upload a build-time .env file 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 diskEAS environment variable
    Where it livesYour machine, gitignoredExpo servers, scoped to development, preview, or production
    Read by expo startYesYes (after eas env:pull)
    Read by eas build (remote)NoYes
    Visible to teammatesNoYes (depending on visibility)
    Visibility levelsPlain filePlain text, Sensitive, Secret
    Can hold a real secretOnly on your machineOnly if visibility is Secret and you do not inline it client-side
    Final destination if inlinedNone (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.example file 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_SECRET without the EXPO_PUBLIC_ prefix. The app runs in dev because the value is in your shell environment, then ships to production with undefined. 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-config in a bare project. The library has no prefix gate; once a screen file imports Config.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.local entry, 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.local as 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:create against the production environment for build-time values, and a server-side store for true secrets that must never sit on the device.
    • Curate a .env.example file 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.
    • #windsurf
    • #env-local
    • #expo
    • #react-native
    • #eas-build
    • #mobile-secrets
    • #ai-coded apps

    Frequently asked questions

    Why does Windsurf AI keep writing code that reads from .env.local in a mobile app?
    Because most training data for environment variables comes from web frameworks like Next.js and Vite, where .env.local is a normal source of runtime configuration. Windsurf reproduces that pattern even when the project is a React Native or Expo app, where the same file is only a build-time convenience and is not packaged into the binary. The generated code looks correct in development and then quietly breaks or leaks once you run eas build.
    Does EAS Build read my .env.local during a production build?
    No. The Expo documentation on EAS Build states that .env and .env.local files are excluded from version control and therefore not available for jobs that run on a remote server. To pass a value into an EAS production build, you create it with eas env:create against the production environment, or upload the file as part of the build job. Local .env.local files only affect local CLI commands.
    Is it safe to put a Supabase anon key or Firebase config in EXPO_PUBLIC_?
    Anon keys and Firebase web config are designed for public exposure, so embedding them is technically supported, but the protection has to come from server-side rules. Supabase Row Level Security and Firebase Security Rules are what actually stop a stranger with your AAB from reading the database. A service role key, a Stripe secret key, or a private API token must never sit behind an EXPO_PUBLIC_ prefix, because the value is inlined into the JavaScript bundle that ships in the binary.
    If I store secrets in EAS as Secret visibility, do they reach my client code?
    Not directly. The Expo documentation explains that Secret variables are not readable outside EAS servers, so they cannot be inlined into your bundle even by accident. They are meant for build-side tools (NPM_TOKEN for private packages, signing credentials), not runtime app behavior. To make a secret usable at runtime, you keep it on a server you control and have the mobile app call an authenticated endpoint.
    Does react-native-config behave the same way as Expo on production builds?
    No. The community react-native-config library reads a .env file at build time and exposes every key, with no EXPO_PUBLIC_ style prefix gate. That means a value you intended for the Node side ends up in the compiled JavaScript on iOS and Android the moment you import it on a client screen. For bare React Native projects driven by Windsurf, the safest pattern is to treat any variable that touches a component file as public and to keep secrets on the server.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free