Backend Development Engineering

Environment Variables Leak Through Your Client Bundle and Nobody Catches It Until Production

How environment variables end up in JavaScript bundles shipped to browsers, why framework conventions make this easy to get wrong silently, and the audit process that catches secrets before they reach users.

Meritshot8 min read
SecurityEnvironment VariablesFrontendNext.jsViteProduction
Back to Blog

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_VARIABLE with 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_VARIABLE is 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.

Security vulnerability in client-side JavaScript bundles


Auditing Your Current Bundle for Leaked Secrets

Before implementing prevention, check whether your current production bundle already contains secrets.

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 .env file (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.

Environment variable security patterns and audit


Incident Response: If You've Already Leaked a Secret

If you find a secret in your production bundle:

Immediately (within minutes):

  1. 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.
  2. Update your application to use the rotated credential.
  3. 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.

Recommended