Environment Variables Leak Through Your Client Bundle and Nobody Catches It Until Production
The security incident looked innocuous at first. A developer was reviewing the JavaScript bundle of a production Next.js application using browser DevTools. While searching for an unrelated bug, they searched the bundle contents for the company's internal API domain and found something unexpected: the full string of the company's database connection URL — including the username, password, and database host — embedded in the JavaScript bundle being served to all users.
The database password had been rotated and the vulnerability closed within hours. But the bundle had been public for four months. The connection string had been cached by CDNs, search engine crawlers, and likely logged by security researchers' passive scanning tools.
The root cause: a developer had prefixed a server-only environment variable with NEXT_PUBLIC_ to make it accessible in an API route (incorrectly; API routes don't need the prefix). That prefix caused Next.js to inline the variable's value into the client bundle.
This is not a Next.js bug. It is an expected behavior that the documentation describes. The problem is that "expected behavior" and "secure by default" are different things.
How Environment Variables End Up in Client Bundles
Modern JavaScript frameworks have a convention-based system for distinguishing server-side from client-side environment variables. Understanding how this works — and where it silently fails — is the foundation of preventing leaks.
Next.js:
- Variables prefixed with
NEXT_PUBLIC_are inlined into the client bundle at build time - Variables without this prefix are only available in server-side contexts (API routes, Server Components,
getServerSideProps) - The prefix is checked at build time; the build tool replaces
process.env.NEXT_PUBLIC_VARIABLEwith the literal value of the variable
Vite:
- Variables prefixed with
VITE_are inlined into the client bundle - Variables without the prefix are undefined in client-side code
import.meta.env.VITE_VARIABLEis replaced with the literal value at build time
Create React App (legacy):
- Variables prefixed with
REACT_APP_are inlined into the client bundle
The mechanism is the same across frameworks: at build time, the bundler performs string replacement on specific variable references, substituting the literal value. This means the value is embedded in the JavaScript file that ships to every user's browser — even if you think of it as an "environment variable."
Why This Goes Undetected
Three conditions make this vulnerability easy to introduce and hard to catch:
1. The failure is silent. Adding NEXT_PUBLIC_ to a server-only variable doesn't cause an error. The build succeeds. The deployment succeeds. The feature works. The secret is now public. No warning, no error, no failed test.
2. The developer intent is plausible. A developer might add NEXT_PUBLIC_ to a variable because they're confused about when the prefix is required, or because they added it in an API route that doesn't actually need client-side access, or because they copied configuration from a client-safe variable and forgot to remove the prefix from a new server-only variable.
3. Code review misses it. A pull request that adds NEXT_PUBLIC_DATABASE_URL to an environment variable reference looks syntactically valid. Reviewers checking the logic of the feature may not notice the security implication of the prefix.

Auditing Your Current Bundle for Leaked Secrets
Before implementing prevention, check whether your current production bundle already contains secrets.
Method 1: Browser DevTools Search
Open your production application in a browser. Open DevTools → Sources tab. Find your main JavaScript bundle. Use Ctrl+F (or Cmd+F) to search for:
- Known API key prefixes (
sk-,pk_live_,rk_live_) - Your database host domain
- Known secret patterns from your
.envfile (first few characters of sensitive values) - Environment variable names that should be server-only
Method 2: Bundle Analysis with grep
Download your production bundle and search it:
# Download the bundle (find the URL from browser DevTools)
curl -s https://yourapp.com/_next/static/chunks/main.js -o main.js
# Search for common secret patterns
grep -oE 'sk-[a-zA-Z0-9]{48}' main.js # OpenAI API key
grep -oE 'pk_live_[a-zA-Z0-9]+' main.js # Stripe public key (expected)
grep -oE 'rk_live_[a-zA-Z0-9]+' main.js # Stripe restricted key (not expected)
grep -oE 'mongodb\+srv://[^"]+' main.js # MongoDB connection string
grep -oE 'postgresql://[^"]+' main.js # PostgreSQL connection string
grep -oE 'redis://[^"]+' main.js # Redis connection string
grep -oE 'AKIA[A-Z0-9]{16}' main.js # AWS access key
Method 3: Automated CI Check
Add a CI step that builds the application and then scans the output bundle:
# Build the application
npm run build
# Search all output JavaScript files for known secret patterns
find .next/static -name '*.js' -exec grep -l 'NEXT_PUBLIC_DATABASE\|NEXT_PUBLIC_SECRET\|NEXT_PUBLIC_PRIVATE' {} \;
# Or use a dedicated tool like trufflehog on the build output
trufflehog filesystem .next/static/
The Categories of Variables That Must Never Be Prefixed
These variable types should never appear in a client bundle:
Database credentials:
- Connection strings (
DATABASE_URL,REDIS_URL,MONGO_URI) - Separate host/user/password components
- Any read/write database credentials
API secret keys:
- Backend API keys that grant server-level access
- Stripe secret keys (
sk_live_*,rk_live_*) - OpenAI API keys (used server-side; costs money if exposed)
- Anthropic API keys
- Any provider's secret or restricted key (as opposed to publishable/public keys)
Service account credentials:
- AWS access keys and secrets
- GCP service account JSON keys
- SSH private keys
- JWT signing secrets
Internal infrastructure details:
- Internal service hostnames
- Internal API base URLs
- Infrastructure identifiers
Variables that are safe to expose (with judgment):
- Stripe publishable keys (
pk_live_*) — designed to be public - Google Maps API keys — designed to be public (restrict by domain in Google Cloud Console)
- Analytics tracking IDs (GA4, Amplitude) — designed to be public
- Feature flag keys for client-side evaluation tools
Prevention: Framework-Level Controls
Next.js: Server-only package
The server-only package causes a build error if a server-only module is imported in a Client Component:
// lib/database.ts
import 'server-only' // This entire module is server-only
export async function getUserFromDatabase(id: string) {
// This function has access to DATABASE_URL
// If someone accidentally imports this in a Client Component, build fails
}
Variable naming convention with lint rules:
Establish a naming convention that distinguishes server-only variables:
# Server-only: never prefix with NEXT_PUBLIC_
DATABASE_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
OPENAI_API_KEY=
# Client-safe: may use NEXT_PUBLIC_ prefix
NEXT_PUBLIC_APP_URL=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
Enforce this with a custom ESLint rule that flags NEXT_PUBLIC_DATABASE, NEXT_PUBLIC_SECRET, NEXT_PUBLIC_KEY, and similar patterns.
Environment variable schema validation:
Use @t3-oss/env-nextjs or similar to define and validate environment variables with explicit server/client separation:
// env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
});
If a server variable is accidentally referenced in a client context, TypeScript will catch it at compile time.

Incident Response: If You've Already Leaked a Secret
If you find a secret in your production bundle:
Immediately (within minutes):
- Rotate the credential — generate a new API key, change the database password, invalidate the JWT secret. This is the only remediation that matters. Removing the secret from the codebase without rotation leaves the exposed credential valid.
- Update your application to use the rotated credential.
- Deploy.
Within hours: 4. Review access logs for the exposed credential. Did any unusual requests use it after the deployment date? 5. Notify your security team or lead if the credential provided access to sensitive data. 6. Determine how long the secret was exposed (first deploy date with the leaked variable).
Within days: 7. Conduct a broader audit — search all bundles for other potential leaks. 8. Implement prevention measures (schema validation, lint rules, CI scanning). 9. Add the bundle scanning step to CI so this class of issue is caught before deployment.
The critical step that gets missed: developers often remove the leaked secret from the codebase and redeploy without rotating the credential. The old bundle with the old credential remains cached on CDNs and in browser caches. The credential is still valid. The exposure continues until the credential is rotated.
The CI Step That Makes This Preventable
Adding a bundle scan to CI catches new leaks before they reach production:
# .github/workflows/security-scan.yml
- name: Build application
run: npm run build
- name: Scan bundle for leaked secrets
run: |
# Check for common secret patterns in bundle output
if find .next/static -name '*.js' | xargs grep -l 'NEXT_PUBLIC_DATABASE\|NEXT_PUBLIC_SECRET\|NEXT_PUBLIC_PRIVATE_KEY' 2>/dev/null; then
echo "ERROR: Potential server-only variable found in client bundle"
exit 1
fi
# Run trufflehog on bundle output for broader pattern coverage
# trufflehog filesystem .next/static/ --only-verified
- name: Verify no database URLs in bundle
run: |
if find .next/static -name '*.js' | xargs grep -qE 'mongodb\+srv://|postgresql://|mysql://|redis://' 2>/dev/null; then
echo "ERROR: Database connection string found in client bundle"
exit 1
fi
The specific patterns to check depend on the credentials your application uses. The principle is the same: build the application in CI, scan the output bundle before deploying, fail the build if anything matches a known-sensitive pattern.
Environment variable leaks are silent vulnerabilities that pass every unit test and integration test. They require a separate class of check: examining the artifact that actually ships to users, not just the source code.





