Security
Secrets in the Client Bundle (Never)
Reading level
The API key anyone can read
You're building a weather widget. You get an API key from the weather service. You add it to your React app as REACT_APP_WEATHER_KEY=abc123. It works. You deploy. A month later, you get an email: your API key has been abused, you're being billed for 2 million requests from an IP in another country. Someone read your key from the JavaScript bundle and used it.
Any environment variable that starts with REACT_APP_, VITE_, or NEXT_PUBLIC_ is baked into your frontend JavaScript. Anyone can read it. Always.
Frontend JavaScript is public. Your bundle is served to every browser that visits your site — and it's readable by anyone with DevTools, or by downloading the bundle directly. There is no obfuscation, minification, or encryption that makes a secret safe in client-side code. The only safe place for API keys is the server.
The class of vulnerability is OWASP A02:2021 — Cryptographic Failures, specifically "transmitting sensitive data in cleartext." The vector: client-side JavaScript is public code. Any "secret" in it is public by definition. Obfuscation is not security; it's security theater that adds minutes of difficulty, not meaningful protection.
Three patterns that keep secrets on the server
Pattern 1: API proxy (simplest) — your frontend calls your own backend; your backend calls the third-party API with the key.
// ❌ Frontend calls third-party directly with key
fetch(`https://api.weather.com?key=${process.env.REACT_APP_KEY}`)
// ✅ Frontend calls your proxy; key lives on server
fetch('/api/weather') // → your server → weather API with key
Pattern 2: serverless function — a Vercel/Netlify/Cloudflare function holds the key and proxies the request. Frontend calls the function URL.
Pattern 3: BFF (Backend for Frontend) — a thin Express/Fastify server routes all third-party API calls, adds auth, and exposes a safe API surface to the frontend.
Which pattern to use:
- Static site (no server) → serverless function (Vercel API routes, Netlify Functions, Cloudflare Workers)
- Next.js / Remix → server-side data fetching (getServerSideProps, loader functions) — key never reaches the client
- SPA with existing backend → add a proxy endpoint to the backend
Environment variables without a public prefix (API_KEY vs NEXT_PUBLIC_API_KEY) are server-only — they never enter the bundle. This is the first line of defense.
Additional server-side controls:
- Key rotation — treat any leaked key as permanently compromised; rotate immediately
- Rate limiting on your proxy — even if someone discovers your proxy endpoint, rate limiting caps abuse
- Allowlist origins — configure the third-party API to only accept requests from your server IP (not client IPs)
- Secret scanning in CI — tools like Gitleaks or GitHub's secret scanning detect keys in code before they ship
The Stripe secret key in production JavaScript
A startup built a Next.js checkout flow. The developer was new to the Stripe API and confused the publishable key (safe for the browser) with the secret key (never for the browser). They set NEXT_PUBLIC_STRIPE_KEY=sk_live_... in their environment file and used it in a client component to initialise a custom Stripe request. The app worked correctly in development and production — Stripe accepted the secret key from client calls.
Three weeks after launch, the Stripe account received a fraud alert: the secret key had been used to create test charges and enumerate customer data from a different IP. A security researcher had downloaded the production JS bundle, searched for "sk_live" using browser DevTools, and found the key in under two minutes. The startup's entire Stripe account — customer cards, charge history, refund ability — was accessible to anyone with the key.
The key was visible in the bundle because NEXT_PUBLIC_ prefix is Next.js's explicit signal to bake a variable into client-side code. The developer used the prefix without understanding what it meant. The minifier did not obfuscate the key value. Any string literal baked into a JavaScript bundle at build time is visible in the shipped file — there is no transformation that makes it a secret.
Secret keys belong exclusively in API routes
The fix was a complete separation: the Stripe secret key moved to an API route (/api/create-payment-intent). The client component calls the API route with the cart total and receives a payment intent client secret in response. The client then uses the Stripe publishable key (genuinely safe to expose) to confirm the payment. The secret key never appears in any client-side code or any bundle.
The correct environment variable convention: STRIPE_SECRET_KEY (no prefix) is server-only — Next.js never includes it in client bundles. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (with prefix) is safe to expose because Stripe publishable keys are designed to be public. The names encode their own security model once you understand the prefix convention.
The pattern generalises: any third-party API key that grants write access, billing access, or data access is a secret key. Only read-only, rate-limited, domain-restricted keys designed for client use should go into the browser. When in doubt, assume a key is secret and proxy it through your server. The proxy adds one network hop; a leaked secret key adds unlimited liability.
Pattern at a glance
// ❌ .env.local (or .env)
NEXT_PUBLIC_STRIPE_KEY=sk_live_abc123secret
// ❌ ClientComponent.tsx — key baked into JS bundle at build time
import Stripe from 'stripe';
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_KEY!);
export async function createPaymentIntent(amount: number) {
// sk_live_abc123secret is visible to anyone who opens DevTools
return stripe.paymentIntents.create({ amount, currency: 'usd' });
}
// ✅ .env.local — no NEXT_PUBLIC_ prefix: server-only
STRIPE_SECRET_KEY=sk_live_abc123secret
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz789public
// ✅ app/api/create-payment-intent/route.ts (server-only)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { amount } = await req.json();
const intent = await stripe.paymentIntents.create({
amount, currency: 'usd'
});
return Response.json({ clientSecret: intent.client_secret });
}
// ✅ ClientComponent.tsx — only publishable key reaches browser
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
Try it: exposed vs proxied key
Open DevTools → Sources → search for "KEY" in the "Broken" bundle. You'll find the fake key in plain text. The "Fixed" version makes the same API call through a proxy — no key in the bundle.
Open DevTools Network → compare the request URLs. Broken: the key is a query parameter visible in the URL (and in server logs). Fixed: request goes to /api/proxy — key is on the server.
Run strings on the minified bundle (cat bundle.js | grep -o 'KEY[^&]*'). Even in production builds with dead-code elimination, environment-variable substitution bakes the key into the output at build time.
Showing: Fixed — proxy pattern
Implementation depth
Bundle analysis is the most direct way to verify secrets are not in your client code. source-map-explorer and @next/bundle-analyzer both produce treemap views of your bundle contents. To check specifically for secrets, run strings on the production bundle output or search the built JS files for known key prefixes:
# Build and inspect — check that no secret key patterns appear
npm run build
# Search the built client JS for Stripe secret key prefix
grep -r "sk_live\|sk_test" .next/static/ 2>/dev/null
# Should return nothing — if it returns a match, a secret is in the bundle
# Alternatively, scan for any .env variable names
grep -r "MY_SECRET_KEY\|DATABASE_URL\|PRIVATE_" .next/static/
Defence in depth for API key security:
- Secret scanning in CI — tools like Gitleaks, truffleHog, or GitHub's native secret scanning detect key patterns in committed code and build artifacts. Add as a required CI check. Treat any key found in git history as permanently compromised — rotation is required even if the commit was reverted.
- Environment variable prefixing conventions —
NEXT_PUBLIC_(Next.js),VITE_(Vite),REACT_APP_(CRA) all signal "safe to expose." Variables without these prefixes are server-only. Never use a public prefix for a secret key, even if it seems convenient. - Rate-limit proxy endpoints — even with server-side key storage, your proxy endpoint is a public surface. Add rate limiting (per-user or per-IP) so that a compromised user session cannot use your proxy to make unlimited third-party API calls on your behalf.
- API key scoping — most APIs allow creating keys with restricted permissions. Create Stripe restricted keys with only the permissions your server needs. A read-only webhook validation key should not also have permission to initiate refunds.
References
Remember
Key takeaways
-
REACT_APP_*, VITE_*, and NEXT_PUBLIC_* variables are baked into your JavaScript. They're public. Never put real secrets in them.The proxy pattern is the fundamental fix: your frontend calls your backend, your backend calls the third-party API with the key — key never reaches the client.Add Gitleaks or GitHub secret scanning to CI — catch keys before they ship. Treat any key committed to git or shipped in a bundle as permanently compromised.
-
Serverless functions (Vercel API routes, Netlify Functions) are the easiest proxy for static sites — no full backend needed.In Next.js, use server-side data fetching (Server Components, getServerSideProps) — the API key is only used server-side and never serialized to the client.Defense in depth: rotate leaked keys immediately, rate-limit proxy endpoints, and configure third-party APIs to allowlist only your server IP.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed