Environment Variables You're Leaking to the Frontend Without Knowing It
You added NEXT_PUBLIC_ to your API key 'just to test something quickly.' That was six months ago. It's still there. Here's what's actually leaking — and how to stop it.
You added NEXT_PUBLIC_ to your API key "just to test something quickly." That was six months ago. It's still there.
Most developers know the rule: secret keys go in .env, never in client code. But the actual leaks aren't that obvious. They don't happen because someone is careless — they happen because the tooling is confusing, the error messages are silent, and the mistakes look completely fine until someone opens DevTools or pulls your bundle.
Mistake 1 — The NEXT_PUBLIC_ prefix on secrets
Next.js exposes any variable prefixed with NEXT_PUBLIC_ to the browser. That's by design. The problem is developers reach for it the moment they hit a "variable is undefined" error on the client side — without asking why it's undefined.
# .env.local
# Fine — this is meant to be public
NEXT_PUBLIC_APP_URL=https://myapp.com
# DANGER — now exposed in your JS bundle
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxx
NEXT_PUBLIC_DATABASE_URL=postgresql://user:password@host/dbAnyone can open your deployed site, go to Network → JS files, search for sk_live_ and find it. Stripe secret keys have a very recognizable prefix. So do AWS access keys (AKIA), Supabase service role keys, and SendGrid API keys.
The fix: if your key is only used in API routes or server components, it should never have NEXT_PUBLIC_. Call a backend route instead.
// Wrong — secret key exposed to client
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
// Right — only used in /app/api/checkout/route.ts (server-side)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Mistake 2 — Vite's import.meta.env and the VITE_ prefix
Vite works exactly the same way. Any variable prefixed with VITE_ gets statically inlined into your bundle at build time. It's not fetched at runtime — it's literally copied into your JavaScript.
VITE_API_URL=https://api.example.com # fine
VITE_SUPABASE_ANON_KEY=eyJhbG... # fine — meant to be public
VITE_SUPABASE_SERVICE_KEY=eyJhbG... # DANGER — never prefix thisRun npm run build, then open dist/assets/index-abc123.js and search for your key. You'll find it, in plain text, wrapped in no protection whatsoever.
Mistake 3 — process.env in bundled client code
With Create React App or older webpack setups, REACT_APP_* variables get inlined. But even without that pattern, developers sometimes write shared utility files that get pulled into the client bundle without realising it.
// utils/config.ts — imported by both client and server code
export const config = {
dbUrl: process.env.DATABASE_URL,
apiSecret: process.env.API_SECRET,
stripeKey: process.env.STRIPE_SECRET_KEY,
};The safe pattern: never import server config into code that's shared with the client.
// lib/server-config.ts — only imported in server files
export const serverConfig = {
dbUrl: process.env.DATABASE_URL!,
stripeKey: process.env.STRIPE_SECRET_KEY!,
};
// lib/client-config.ts — safe to import anywhere
export const clientConfig = {
appUrl: process.env.NEXT_PUBLIC_APP_URL!,
};Mistake 4 — Exposing .env through source maps
You've been careful with your variables. But did you ship source maps to production? Source maps reconstruct your original source code in the browser. If your server-side code ends up with a source map in your production build, the DevTools Sources tab will show the original file — including any hardcoded fallbacks.
// A common but dangerous pattern
const key = process.env.STRIPE_SECRET_KEY || "sk_live_fallback_for_dev";In Next.js, make sure source maps stay off in production:
// next.config.js
module.exports = {
productionBrowserSourceMaps: false, // default is false — make sure it stays that way
};For Vite:
// vite.config.ts
export default defineConfig({
build: {
sourcemap: false, // or 'hidden' if you need them for error tracking only
},
});Mistake 5 — The /api/debug route you forgot to remove
This one is embarrassing but common. During local debugging, you add a quick route to dump env state. You commit it. It goes to production. https://yourapp.com/api/debug now returns every single environment variable on your server.
// pages/api/debug.ts
export default function handler(req, res) {
res.json({ env: process.env }); // "just for debugging locally"
}Search your codebase before every deploy:
grep -r "process.env" pages/api/ --include="*.ts" | grep -v "NODE_ENV\|NEXT_PUBLIC"How to audit what you're actually shipping
Build your app and grep the bundle for known secret patterns:
# Next.js
npm run build
grep -r "sk_live\|AKIA\|password\|secret" .next/static/chunks/
# Vite
npm run build
grep -r "sk_live\|AKIA\|password\|secret" dist/assets/Common mistakes
- Prefixing secrets "temporarily" to fix a client-side undefined error — the root cause is architectural, not a missing prefix
- Sharing a single config.ts between server and client code — split them and enforce it with ESLint import rules
- Using || fallbacks with real keys — process.env.KEY || "real-key-here" defeats the entire point
- Forgetting that third-party SDKs initialized client-side log their config — Sentry, Supabase, and Firebase initialized with wrong keys expose them in network requests
The takeaway
The leaks that hurt you aren't the obvious ones — they're the NEXT_PUBLIC_ you added in a rush, the shared config file that got bundled, the debug route that made it to production. Build a habit: before any deploy, grep your bundle for known key patterns, keep a hard separation between server config and client config, and treat every environment variable as guilty until proven safe to expose.
Related Articles
You might also enjoy these
How to Create and Publish Your First npm Package (and Use It in Angular)
This guide explains how to convert a TypeScript utility into a reusable npm package and use it in an Angular app. It covers project setup, configuration, publishing, and how to install, import, and run it in Angular, including common issues and solutions.
Angular Signals Forms — Replace ReactiveFormsModule in New Projects
Reactive forms were the right solution for 2018. Angular 21 ships Signal-based Forms — no valueChanges, no async pipe, no subscription management. Here's how to replace ReactiveFormsModule in every new component you write.
Stay in the loop
Get articles on technology, health, and lifestyle delivered to your inbox.
No spam — unsubscribe anytime.
