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:
| Prefix | Available in | Visible in bundle |
|---|---|---|
NEXT_PUBLIC_ | Browser + Server | Yes |
| (no prefix) | Server only | No |
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
}
| Prefix | Available in | Visible in bundle |
|---|---|---|
PUBLIC_ | Browser + Server | Yes |
| (no prefix) | Server only | No |
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 location | Available in | Visible in bundle |
|---|---|---|
runtimeConfig.public.* | Browser + Server | Yes |
runtimeConfig.* (root level) | Server only | No |
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:
- Create a simple proxy on a separate server (Node.js, PHP, Cloudflare Worker) that holds the key, and call that from Tilda.
- 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.