vibeblame
Guides

How to fix API key leaks in JavaScript bundles

API keys in your frontend bundle are readable by anyone. Learn the right way to handle secrets in Next.js, Astro, Vue (Nuxt), Angular, WordPress, and PHP.

Why frontend secrets are always exposed

Frontend JavaScript is public. Everything in your JS bundle can be read by anyone who opens DevTools -> Sources, or downloads the file directly. There is no way to hide a secret on the client side.

Common mistakes: putting a secret key in a React component, using NEXT_PUBLIC_STRIPE_SECRET_KEY thinking it's safe, calling a third-party API directly from the browser with a key that has write access.

Rule: if a key gives write access, payment access, or access to user data — it must never reach the browser.


Find leaks in your existing bundle

npm run build

# Next.js
grep -r "sk_live\|AIza\|ghp_\|xoxb-\|NEXT_PUBLIC_" .next/static/

# Vite / CRA / Astro
grep -r "sk_live\|AIza\|ghp_\|xoxb-" dist/

# Nuxt
grep -r "sk_live\|AIza\|ghp_\|xoxb-" .output/public/

# Angular
grep -r "sk_live\|AIza\|ghp_\|xoxb-" dist/

Next.js

// Wrong — secret key ends up in the browser bundle
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)

// Right — server-only
// app/api/checkout/route.ts
export async function POST(req: Request) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // no NEXT_PUBLIC_
}

Server Actions (Next.js 14+):

// app/actions.ts
'use server'

export async function createCheckoutSession(priceId: string) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
  const session = await stripe.checkout.sessions.create({ ... })
  return session.url
}

Environment variable rules in Next.js:

PrefixAvailable inVisible in bundle
NEXT_PUBLIC_Browser + ServerYes
(no prefix)Server onlyNo

Only use NEXT_PUBLIC_ for values that are genuinely safe to be public: analytics IDs, Stripe publishable key, map API keys restricted by domain.


Astro

Astro uses the same pattern — the PUBLIC_ prefix makes a variable available in the browser:

// Wrong — goes to the browser
const key = import.meta.env.PUBLIC_STRIPE_SECRET_KEY

// Right — server-only (in .astro files or API endpoints)
// src/pages/api/checkout.ts
export async function POST({ request }) {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY) // no PUBLIC_ prefix
}
PrefixAvailable inVisible in bundle
PUBLIC_Browser + ServerYes
(no prefix)Server onlyNo

Vue (Nuxt)

Nuxt 3 uses runtimeConfig to separate public and private environment variables:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only — never reaches the browser
    stripeSecretKey: process.env.STRIPE_SECRET_KEY,

    public: {
      // Available on both server and client — visible in bundle
      stripePublishableKey: process.env.NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
    },
  },
})

Access in server-only code:

// server/api/checkout.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const stripe = new Stripe(config.stripeSecretKey) // server-only
  // ...
})

Access in components (public values only):

// components/PaymentButton.vue
const config = useRuntimeConfig()
const publishableKey = config.public.stripePublishableKey // safe
Config locationAvailable inVisible in bundle
runtimeConfig.public.*Browser + ServerYes
runtimeConfig.* (root level)Server onlyNo

Angular

Angular compiles entirely to static JS — there is no server runtime in a standard Angular app. Everything in environment.ts ends up in the bundle.

// Wrong — ends up in the browser bundle
// src/environments/environment.prod.ts
export const environment = {
  production: true,
  stripeSecretKey: 'sk_live_abc123', // visible to anyone
}

The only correct approach: never put secret keys in Angular files. Route all sensitive API calls through your backend:

// Wrong — calling Stripe directly from Angular
createPayment() {
  return this.http.post('https://api.stripe.com/v1/charges', data, {
    headers: { Authorization: `Bearer ${environment.stripeSecretKey}` }
  })
}

// Right — call your own backend, which holds the key
createPayment(cart: Cart) {
  return this.http.post('/api/checkout', cart)
}

For Angular Universal (SSR), secrets can live in server-side Express code:

// server.ts (Express)
app.post('/api/checkout', async (req, res) => {
  const stripe = new Stripe(process.env['STRIPE_SECRET_KEY']!)
  // ...
})

PHP

PHP runs on the server — secrets never reach the browser unless you explicitly print them into HTML or JavaScript.

Storing secrets in a file accessible via the web:

// Wrong — config.php in the public folder
$apiKey = 'sk_live_abc123';

// Right — use .env and store it above the web root
// .env at /var/www/ (not in /var/www/html/)
STRIPE_SECRET_KEY=sk_live_abc123

Printing secrets into JavaScript:

// Wrong — key visible in page source
<script>
  const key = "<?= $stripeSecretKey ?>";
</script>

// Right — call your PHP API from JS, never expose the key
<script>
  fetch('/api/checkout.php', { method: 'POST', body: JSON.stringify(cart) })
</script>

Block direct access to .env:

# .htaccess
<FilesMatch "^\.env">
  Require all denied
</FilesMatch>

WordPress

Never put API keys directly in theme files or functions.php:

// Wrong — visible to anyone with FTP access and in version control
define('STRIPE_SECRET_KEY', 'sk_live_abc123');

// Right — use wp-config.php (above web root) or a .env plugin
// wp-config.php
define('STRIPE_SECRET_KEY', getenv('STRIPE_SECRET_KEY'));

For plugins that ask for API keys in the admin panel: the key is stored in the database. This is acceptable as long as your admin panel has strong credentials and is not publicly accessible.

If you have a custom Gutenberg block or theme that calls an API from the frontend, route the request through a WordPress REST API endpoint or admin-ajax.php so the key stays on the server.


Tilda

Tilda does not allow custom server-side code. If you need to call a sensitive API, two options:

  1. Create a simple proxy on a separate server (Node.js, PHP, Cloudflare Worker) that holds the key, and call that from Tilda.
  2. Use only APIs designed for direct browser use with domain-restricted keys (e.g. Google Maps with HTTP referrer restrictions).

Do not put secret keys in Tilda HTML/JS blocks — they will be visible in the page source.


For keys that must be in the browser

Restrict them in the provider's dashboard. In Google Cloud Console: Credentials -> API key -> Application restrictions -> HTTP referrers -> add only your production domain. A leaked domain-restricted key can only be used from your site.


After fixing

Rebuild and search again:

npm run build
grep -r "sk_live\|AIza\|ghp_" .next/static/

Rotate any key that was previously exposed. A key that has been in a public bundle should be considered compromised.

How to fix API key leaks in JavaScript bundles | vibeblame