Skip to main content

Why React Apps Leak Secrets: Environment Variables 101

React apps leak secrets because developers confuse client-side bundling with runtime execution: any variable you read in your React code gets baked into the final JavaScript bundle, visible to anyone who opens the DevTools. This happens regardless of whether you use a .env file—the build tool reads it at compile time, not at runtime. The critical rule is simple: if a variable's value is rendered, logged, or fetched in a React component, it will be exposed in the browser. You must run sensitive operations (API calls, credential validation) on a backend server instead.

How React Bundles Expose Secrets

When you run npm run build or vite build, the build tool scans your source code for references to environment variables (via process.env.* or import.meta.env.* depending on your bundler) and replaces them with literal values. This is called static analysis and substitution. The .env file is read during the build process—not by the browser. Once the bundle is shipped to the browser, there is no .env file anymore, only hardcoded strings.

The Build-Time vs. Runtime Myth

Many developers think .env files work like server-side environment variables (which are loaded by the OS at runtime). They don't. On the client, they are build artifacts. Here is what actually happens:

// Your React component
const API_KEY = process.env.REACT_APP_API_KEY;
console.log(`Using key: ${API_KEY}`);

// After build, the bundler replaces it with:
const API_KEY = "sk_live_1234567890abcdef";
console.log(`Using key: sk_live_1234567890abcdef`);

The user's browser receives the second version. Your secret is now in their JavaScript file, visible in the Network tab, in a minified bundle, and cached on their disk.

Why This Matters: Real-World Impact

According to GitGuardian's 2025 Secrets Exposure Report, over 12 million secrets are leaked per year on GitHub alone—32% of them API keys and tokens left in client-side code. A leaked Stripe API key can allow an attacker to drain your entire Stripe account. A leaked database connection string gives direct access to user data. This is not a theoretical risk; it happens in production every month.

The problem is visibility. Once you ship a bundle with a secret, you cannot unship it. Even if you rotate the key, the old one lives in browser caches, CDN archives, and user download histories. The attacker has already copied it.

How to Identify Secrets in Your Current React App

Open DevTools and check:

  1. Network tab: search for API key patterns (sk_, pk_, AIza, ghp_, etc.)
  2. Console: grep logs for sensitive keywords
  3. Application / Storage: check localStorage, sessionStorage, cookies
  4. Sources tab: search the minified bundle itself for key patterns

If you find anything, assume it is already compromised and rotate it immediately.

The Correct Architecture: Backend Proxy Pattern

The only safe way to use secrets in React is to keep them on your server. Your React app makes an HTTP request to your own backend, which then authenticates with the third-party service using the real secret. The backend acts as a proxy:

// React component (safe—no secrets exposed)
async function fetchUserData(userId) {
const response = await fetch("/api/users/" + userId);
const data = await response.json();
return data;
}

// Backend (Node/Express, only server can see this)
app.get("/api/users/:userId", async (req, res) => {
const stripeSecret = process.env.STRIPE_SECRET_KEY; // Safe—read at runtime, never sent to browser
const user = await stripe.customers.retrieve(req.params.userId, {
apiKey: stripeSecret
});
res.json(user);
});

In this pattern, your React code never touches the Stripe secret. The browser makes a request to your server, your server uses the secret privately, and returns only the safe data to the client.

What React Environment Variables Are Actually For

React environment variables (prefixed with REACT_APP_ in Create React App, or any prefix in Vite if configured) exist for two purposes:

  1. Non-secret configuration that needs to change between environments (e.g., REACT_APP_API_ENDPOINT=https://api.example.com for production vs. http://localhost:3000 for development).
  2. Build-time toggles for feature flags or debugging (e.g., REACT_APP_DEBUG=true).

Never use them for authentication tokens, private keys, database URLs, or any credential that would grant an attacker access to your systems.

The OWASP Perspective

The OWASP Top 10 for web applications (2025 edition) lists "Cryptographic Failures" and "Injection" as the #2 and #3 risks. Exposing secrets in client-side code violates both: cryptographic failure (your secret is no longer secret), and it enables injection attacks because an attacker can now make authenticated requests on your behalf.

Key Takeaways

  • React bundles are static artifacts: all environment variables are replaced at build time with literal values.
  • Anything readable in DevTools is not a secret, and anything not a secret should not be in a .env file.
  • Use a backend proxy to keep credentials server-side.
  • Assume any secret ever shipped to a client has been compromised and rotate it.
  • REACT_APP_ prefixed variables are for non-sensitive configuration only.

Frequently Asked Questions

Can I use environment variables to hide API keys from my source code repository?

No. Environment variables prevent the secret from appearing in your git history, but once the build completes, they are baked into the bundle and visible to anyone with the compiled JavaScript. Use them for source control security, not client-side security. For true security, keep secrets on the server.

Is minification or obfuscation enough to protect my API keys in the bundle?

No. Minification is trivial to reverse with online tools like js-beautify. Obfuscation can be undone with enough effort. Neither prevents an attacker from inspecting the Network tab or searching the bundle for known key patterns. The only safe approach is to not ship the secret at all.

What if I only use my API key in useEffect and never log it—is it still unsafe?

Yes. The value still exists in the compiled JavaScript. An attacker does not need to see your code logic; they can search the bundle file for the literal string value of the key and find it. Every reference, no matter where, will be substituted during build.

Can I encrypt my API key in the bundle and decrypt it at runtime?

This is security theater. If the decryption key is in the JavaScript, an attacker can extract both the encrypted key and the decryption logic, rendering the encryption useless. Encryption only works if the key holder (your server) is separate from the person trying to access it.

Do serverless functions like AWS Lambda change the secret storage problem?

No. Serverless functions are still "backends" from React's perspective. Store your secrets in the function's environment variables (which AWS manages securely), and call the function from your React app via HTTPS. The principle remains: keep secrets away from the client.

Further Reading