Securing Environment Variables in Next.js and Flutter

Hi I am Kasinadhsarma
Environment variables are the easiest thing to get wrong in a production app. They're convenient — drop a value in .env, read it back with one line of code — and that convenience is exactly why API keys, database URLs, and Firebase configs end up leaking into client bundles, APKs, and public GitHub repos. Here's how I handle env security across the two stacks I ship on most: Next.js on the web and Flutter for mobile/desktop apps.
Next.js: process.env is safe by default, until you export it
Next.js gives you process.env out of the box, and the framework's own build step is your first line of defense — it statically replaces process.env.* references at build time and only inlines variables prefixed with NEXT_PUBLIC_ into the client bundle. Anything without that prefix stays server-side: API routes, server components, and middleware can read it, but it never ships to the browser.
The practical rules I follow:
Anything that touches a secret key, a database connection string, or a third-party API secret has no
NEXT_PUBLIC_prefix. If it doesn't need to run in the browser, don't give it browser access..env.local(and.env*.localin general) is git-ignored by default in a Next.js project — keep it that way, and never commit.env.productionwith real values either. Only commit an.env.examplewith placeholder keys so collaborators know what to set.On Vercel, environment variables are configured per environment (Development, Preview, Production) in the dashboard rather than in the repo. CMS tokens and payment provider keys get scoped to Production only, so a preview deploy from a feature branch never has access to live credentials.
Vercel's serverless/edge sandbox isolates each deployment's execution environment, so a variable set for one project or environment doesn't bleed into another — but that isolation only protects you if you've actually kept the secret server-side in the first place. Sandboxing doesn't fix a
NEXT_PUBLIC_mistake.
If you need a secret at build time (say, to fetch content from a headless CMS during static generation) but never want it in the output HTML/JS, keep it unprefixed and only reference it inside getStaticProps/server components/route handlers — never inside a client component.
Flutter: there's no such thing as a client-side secret
This is the mindset shift that matters most. A Next.js app has a real server boundary — code that literally never leaves your infrastructure. A Flutter app ships as a compiled binary that runs entirely on someone else's device. Anything bundled into that binary — a .env file included as an asset, a string baked in with --dart-define, a Firebase config — can be extracted by anyone with the APK/IPA and a bit of patience (strings, an APK unzip, a decompiler). Flutter env security is really about raising the cost of extraction and locking down the device-level attack surface, not achieving true secrecy for anything embedded in the app.
With that caveat, here's the layered approach:
Loading config values. I use flutter_dotenv to load a .env file at runtime instead of hardcoding values:
import 'package:flutter_dotenv/flutter_dotenv.dart';
appId: _env('FIREBASE_LINUX_APP_ID'),
This keeps per-platform Firebase config (Linux, Android, iOS, macOS, web each get their own app ID) out of source control and out of a single hardcoded firebase_options.dart. It's the right pattern for keeping config manageable and out of git — just remember the .env still ships as an asset inside the app package, so treat any value in it as recoverable, not secret. For anything genuinely sensitive, --dart-define-from-file at compile time is a slightly stronger option since the values aren't sitting in a loose asset file, though they're still embedded in the compiled binary and extractable with effort.
Android hardening. In AndroidManifest.xml, I set:
android:allowBackup="false"
android:fullBackupContent="false"
allowBackup="true" is the default, and it lets adb backup pull app data — including SharedPreferences, local databases, and cached files — off a device without root. If your app caches tokens or config locally, disabling backup closes off that extraction path.
iOS/macOS hardening. In the entitlements file (and I make sure the same requirement applies to the debug entitlements, not just Release.entitlements, since a debug build under App Sandbox needs it too):
<!-- See Release.entitlements — same requirement applies to debug builds under App Sandbox. -->
<key>com.apple.security.network.client</key>
<true/>
App Sandbox on macOS blocks outbound network access unless you explicitly declare the network.client entitlement — this isn't strictly a "hide the secret" measure, it's what keeps a sandboxed macOS build from silently failing every network call (including the one that fetches your remote config or talks to Firebase). Worth checking both entitlement files so a debug build doesn't behave differently from release.
The general checklist
Across both stacks, the practices that actually move the needle:
.env*files are git-ignored everywhere, with a checked-in.env.examplefor onboarding.Anything that must stay secret runs server-side (Next.js API routes/server components) — never in a Flutter client, never behind
NEXT_PUBLIC_.Environment values are scoped per deployment environment (Vercel's dashboard, CI secrets) rather than duplicated across branches or hardcoded.
For Flutter, assume every embedded value is recoverable and design around it: real secrets (payment keys, private API credentials) go through a backend you control, and the app only ever talks to your own server, which then talks to the third party.
Platform-level hardening —
allowBackup="false"on Android, correct entitlements on iOS/macOS — closes off the easy, tooling-based extraction paths even if it can't stop a determined reverse engineer.Rotate keys when a collaborator leaves a project or a repo's visibility changes, and audit
.env.exampleagainst production periodically so it doesn't quietly drift into containing real values.
The short version: on the web, the framework gives you a real client/server boundary — use it. On mobile and desktop, there is no such boundary, so the job shifts from "hide the secret" to "keep secrets off the device entirely, and make everything that is on the device as hard to lift as reasonably possible."



