Skip to main content

Keeping API Keys Out of Your React Bundle

React applications that call external APIs must never embed the API key in the bundle. Instead, they make requests to a backend server, which authenticates with the third-party service using the real key. This backend proxy pattern is the gold standard for API security—it keeps secrets server-side, enables fine-grained rate limiting and audit logs, and lets you rotate keys without redeploying the frontend. If you are currently calling Stripe, Firebase, OpenAI, or any paid API directly from React, your API keys are already exposed and must be rotated immediately.

The Danger of Direct API Calls from React

When you call an external API directly from your React app, you must authenticate somehow. If you embed the key as a string, it is in the bundle. If you try to load it from a .env file, it is still in the bundle (as you learned in article 1). Even if you load it from localStorage at runtime, an attacker can inspect that value in the DevTools Console.

Consider this vulnerable pattern:

// UNSAFE: Do NOT do this
const stripeKey = process.env.REACT_APP_STRIPE_SECRET_KEY;

function handlePayment(amount) {
fetch("https://api.stripe.com/v1/payment_intents", {
method: "POST",
headers: {
"Authorization": `Bearer ${stripeKey}`
},
body: `amount=${amount}`
});
}

An attacker opens DevTools, searches for the Stripe API key pattern in the Network tab, and finds your secret. They can now make charges to your Stripe account, view customer data, and refund legitimate transactions. Stripe reports that compromised API keys cause an average of $2,500 in fraudulent charges before detection.

Backend Proxy Pattern: The Secure Alternative

Instead of calling the external API directly, create an endpoint on your own backend server that accepts safe parameters and makes the authenticated call server-side:

// React component (safe—no secrets exposed)
function CheckoutForm() {
const [loading, setLoading] = React.useState(false);

async function handleSubmit(e) {
e.preventDefault();
setLoading(true);

// Call your own backend, not Stripe directly
const response = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: 2999, // in cents
currency: "usd"
})
});

const { clientSecret } = await response.json();
// Use clientSecret with Stripe.js (which is designed for client use)
// ...
}

return (
form onSubmit={handleSubmit}>
<button disabled={loading}>Pay</button>
/form>
);
}

Your Node.js backend:

// Backend (Express example)
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // Secret stays private

app.post("/api/create-payment-intent", async (req, res) => {
const { amount, currency } = req.body;

// Validate inputs server-side
if (amount <= 0) {
return res.status(400).json({ error: "Invalid amount" });
}

// Use the real secret key to create the payment intent
const intent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true }
});

// Return only the client-safe secret to React
res.json({ clientSecret: intent.client_secret });
});

Now the flow is:

  1. React sends safe data to your backend (amount, currency, user ID, etc.).
  2. Your backend validates the input, applies business logic, and calls Stripe with the real secret.
  3. Your backend returns only the client-safe response (clientSecret, not the full intent).
  4. React receives the clientSecret and uses it with Stripe.js, which is designed for client-side use.

Stripe is a sophisticated service—it provides a special "publishable key" for client-side operations and a "secret key" for server-side operations. Always use the publishable key in React, and the secret key only in your backend.

Rate Limiting and Audit Logs on the Backend

An additional benefit of the backend proxy: you can implement fine-grained rate limiting and audit logs. For example:

// Middleware to track API calls per user
app.use((req, res, next) => {
const userId = req.user?.id;
const endpoint = req.path;

// Log the call
console.log(`[${new Date().toISOString()}] User ${userId} called ${endpoint}`);

// Check rate limit (e.g., 10 calls per minute per user)
const key = `${userId}:${endpoint}`;
const count = rateLimitCache.get(key) || 0;

if (count > 10) {
return res.status(429).json({ error: "Too many requests" });
}

rateLimitCache.set(key, count + 1);
next();
});

app.post("/api/create-payment-intent", authenticateUser, async (req, res) => {
// Now you know exactly which user made this call
// You can log suspicious patterns, block abusers, and debug issues
// ...
});

This audit trail is invaluable for detecting fraud and debugging issues. If an attacker compromises your API key when it is embedded in the React bundle, you have no audit trail and no way to stop them until Stripe or your payment processor alerts you.

API Key Rotation Without Frontend Redeployment

When an API key is compromised, you must rotate it. With the backend proxy pattern, rotation is instant: you update the environment variable on your server, and the next request uses the new key. No frontend deployment, no cache invalidation, no user disruption.

# On your backend server (e.g., Vercel, Heroku, AWS Lambda)
# Update the environment variable via your deployment platform
vercel env add STRIPE_SECRET_KEY sk_live_new_key_here

# The change is live immediately; no rebuild needed

Without the proxy pattern (embedding keys in React), you must rebuild and redeploy the entire frontend, clear CDN caches, and hope users update their cached bundles. If a user runs an old cached version, their requests still use the compromised key.

Testing API Calls Safely

When you test your backend API endpoints, you should test with dummy API keys from the third-party service. Stripe, for example, provides test keys that simulate payment behavior without charging real money:

// In your test environment
process.env.STRIPE_SECRET_KEY = "sk_test_4eC39HqLyjWDarhtT657"; // Stripe's test key

app.post("/api/create-payment-intent", async (req, res) => {
// Calls to Stripe with the test key are sandboxed and won't charge anything
const intent = await stripe.paymentIntents.create({
amount: 2999,
currency: "usd"
});
res.json({ clientSecret: intent.client_secret });
});

Create a separate .env.test file with dummy keys and never use production keys in tests.

Common API Key Patterns and What to Rotate

If you find any of these patterns in your React bundle, rotate the key immediately:

PatternServiceExampleRisk
sk_live_ or sk_test_Stripesk_live_1234567890Full Stripe account access
pk_live_ or pk_test_Stripe (publishable)pk_live_abcd1234Safe to expose (but test keys should rotate)
AIzaGoogle APIsAIzaSyDl-xNg8p-y3...All enabled Google services
ghp_GitHub Personal Access Tokenghp_1234567890abcdefFull repository access
sk_ followed by 32 charsGeneric API keysk_1234567890abcdefghijklmnopqrstVaries by service

Key Takeaways

  • Never embed API keys (especially secret keys) in React bundles.
  • Create a backend proxy: React calls your server, your server calls the external API with the real key.
  • Use the service's client-side mechanism where available (e.g., Stripe.js with publishable key, Firebase SDK with browser configuration).
  • Implement rate limiting and audit logs on your backend proxy.
  • Rotate compromised keys immediately; with a backend proxy, no frontend redeployment is needed.
  • Test with dummy/sandbox keys, never production keys.

Frequently Asked Questions

Can I use a third-party Backend-as-a-Service (BaaS) like Firebase instead of building my own backend?

Yes. Firebase, Supabase, and similar services are built with client-side security in mind. They use a public API key + a project ID in the frontend, and fine-grained security rules on the backend. The public key is intentionally safe to expose. However, you must still configure Firebase security rules to prevent unauthorized access—default rules are often too permissive.

Is the Stripe publishable key safe to embed in React?

Yes. The Stripe publishable key is explicitly designed for client-side use. It cannot make charges, refund transactions, or access customer data. You are expected to put it in your frontend code. The secret key, however, must never leave your backend.

What if I need to make a call to an API that doesn't support server-side authentication?

This is rare but happens with some third-party services. In that case, you must still proxy through your backend. Your backend receives the request, makes the call to the third-party API, and returns the result. This way, any credential or authentication token lives server-side.

How do I prevent users from calling my backend API proxy excessively?

Implement rate limiting, API key authentication for your own endpoints, and require user login before allowing sensitive operations. You can also track usage per user and ban abusers. Without a backend, you have no way to identify or block attackers.

If I use environment variables in a serverless function (AWS Lambda, Vercel), are secrets safe?

Yes. Serverless function environment variables are stored securely by the provider and are never shipped to the browser. The function reads them at runtime, uses them privately, and returns only safe data to the client. This is as secure as a traditional backend server.

Further Reading