THE FULL GUIDE / EP 01 · ASTER × CLAUDE
Your API key is public? How to protect API keys in your frontend
Anything that starts with NEXT_PUBLIC_ or VITE_ ships to every visitor. Here’s how to find a leaked key in two minutes, move it to the server with copy-paste code, and rotate it properly if it already reached GitHub.

Commented KEYS? Here’s everything we promised: the checklist, the code, and why each step matters.
Jump to a story
- Watch the reel
- The checklist
- 01Why your secret key ends up in the browser
- 02Which keys are safe in the browser, and which never are
- 03Keep the key on the server, with copy-paste code
- 04.env and .gitignore, done right
- 05Already pushed it to GitHub? Rotate first
- 06Don’t hide it with CSS (and other myths)
- 07Make it impossible to leak next time
- FAQ
- Source notebook
THE STORY IN MOTION
Watch the episode.
Then fix it for real.
The episode tells the story in 90 seconds. This guide has the code.
Article published · Oct 1, 2026Why your secret key ends up in the browser
You asked your AI assistant to “make checkout work, fast”. It did. It also put your Stripe secret key in a variable called NEXT_PUBLIC_STRIPE_SECRET_KEY, because that was the quickest way to make the error go away.
Here’s the catch. Next.js only lets the browser read environment variables that start with NEXT_PUBLIC_. To make that work, it replaces every reference with the literal value at build time and ships it inside the JavaScript that every visitor downloads. Vite does the same with VITE_, Expo with EXPO_PUBLIC_, and Create React App with REACT_APP_.
# Shipped to every visitor's browser
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Hx...
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-...# Public by design: fine in the browser
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51Hx...
# Server only: no public prefix
STRIPE_SECRET_KEY=sk_live_51Hx...
OPENAI_API_KEY=sk-proj-...How anyone can find it (and how you can check first)
Open your live site, press F12, go to the Sources (Chrome) or Debugger (Firefox) tab, and search all files for sk_live or sk-. If it’s there, everyone can see it. You can run the same check locally against the production build before you deploy:
# Next.js: build, then search the files that ship to the browser
npm run build
grep -rEo "sk_(live|test)_[A-Za-z0-9]{8}|sk-[A-Za-z0-9_-]{12}|sb_secret_|service_role" .next/static | head
# Vite / React: the browser bundle lives in dist/
grep -rEo "sk_(live|test)_[A-Za-z0-9]{8}|sk-[A-Za-z0-9_-]{12}|sb_secret_|service_role" dist | headNo output is what you want. Any match is a key you need to move to the server and rotate.
THE BUILDER’S TAKEAWAYTreat everything in the browser like a postcard: anyone who handles it can read it. Secrets belong in a sealed envelope, which is your server.
Which keys are safe in the browser, and which never are
Not every key is a secret. Many services give you two kinds on purpose: a publishable key that only identifies your project, and a secret key that can do anything. The mistake is mixing them up.
| Service | Safe in the browser | Server only |
|---|---|---|
| Stripe | pk_live_… / pk_test_… publishable key | sk_… secret keys and rk_… restricted keys |
| Supabase | Publishable key (sb_publishable_…) or legacy anon key, only with RLS on | Secret key (sb_secret_…) or legacy service_role. They bypass Row Level Security |
| OpenAI, Anthropic and other LLM APIs | Nothing | Every API key. Proxy requests through your server |
| Firebase (web config) | apiKey in the web config identifies the project; your Security Rules protect the data | Service account JSON and Admin SDK credentials |
| Your own backend | Short-lived user session tokens | Database URLs, signing secrets, webhook secrets |
THE BUILDER’S TAKEAWAYBefore you paste a key anywhere, check its prefix. pk_ and publishable keys can be public. sk_, rk_, sb_secret_, service_role and any LLM key can’t.
Keep the key on the server, with copy-paste code
The pattern is always the same: the browser asks your server, your server talks to the API, and the key never leaves the server. In Next.js that’s a route handler. In a Vite or React app it’s a small serverless function, or a Supabase Edge Function.
1. Put the client in a server-only module
import 'server-only' // build fails if a client component imports this file
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)Install the guard once with npm i server-only. If this file is ever imported into the browser bundle, the build breaks instead of leaking your key.
2. Create the checkout session on the server
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
// Never trust prices from the browser: only allow your own price IDs
// (copy them from Stripe Dashboard → Product catalog).
const PRICES = new Set([process.env.STRIPE_PRICE_BASIC!, process.env.STRIPE_PRICE_PRO!])
export async function POST(req: Request) {
const { priceId } = await req.json()
if (!PRICES.has(priceId)) {
return NextResponse.json({ error: 'Unknown price' }, { status: 400 })
}
const origin = new URL(req.url).origin
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
})
return NextResponse.json({ url: session.url })
}3. Call your endpoint from the browser
'use client'
export function BuyButton({ priceId }: { priceId: string }) {
async function checkout() {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url // Stripe-hosted checkout
}
return <button onClick={checkout}>Upgrade</button>
}The browser only ever sees a price ID and a checkout URL. Your secret key stays in process.env on the server, and it’s never bundled because it has no public prefix.
Same idea for AI keys: a tiny proxy
LLM keys are the most-leaked secrets in vibe-coded apps, and a leaked one can run up a huge bill overnight. Route every model call through your server, and check who’s calling:
import 'server-only'
import { NextResponse } from 'next/server'
import { getUser } from '@/lib/auth' // your auth helper
export async function POST(req: Request) {
const user = await getUser(req)
if (!user) return NextResponse.json({ error: 'Sign in first' }, { status: 401 })
const { message } = await req.json()
const res = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-5-mini', input: String(message).slice(0, 4000) }),
})
return NextResponse.json(await res.json(), { status: res.status })
}Add per-user rate limits and a spending cap in your provider’s dashboard too. Use whichever model your app needs; the key handling is what matters here.
THE BUILDER’S TAKEAWAYIf the code runs in the browser, it can’t keep a secret. Give every secret one home, a server-only module, and let the browser call an endpoint.
.env and .gitignore, done right
Your .env files hold the real values, so they should never be committed. Commit a .env.example instead, with the variable names and placeholder values. Teammates and your AI assistant can see what’s needed without seeing the secrets.
# local env files
.env
.env*.local
.env.development
.env.production
# but keep the template
!.env.exampleNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_me
STRIPE_SECRET_KEY=sk_test_replace_me
OPENAI_API_KEY=replace_meAdding a file to .gitignore doesn’t untrack it if git already knows it. Check, and untrack it if needed:
# Is any env file tracked? (only .env.example should show up)
git ls-files | grep -i "\.env"
# Stop tracking it, but keep your local copy
git rm --cached .env.local
git commit -m "Stop tracking local env file"In production, set secrets in your host’s environment settings (Vercel, Netlify, Railway, Fly). They’re injected at runtime or build time and never live in your repository.
THE BUILDER’S TAKEAWAYReal values live in .env (ignored) and in your host’s settings. Names and placeholders live in .env.example (committed).
Already pushed it to GitHub? Rotate first
Deleting the file in a new commit doesn’t remove the key. It’s still in your git history, in every clone and fork, and possibly already scraped. Bots watch public pushes for keys within minutes. Assume the key is compromised and change the locks.
- Rotate or revoke the key now. Stripe: Dashboard → Developers → API keys → ⋯ → Rotate key, set expiration to “Now” if it’s compromised. Supabase: create a new secret key, switch your servers to it, then delete the old one. OpenAI and other providers: revoke the key and create a new one.
- Update every place that used it: your host’s environment variables, CI secrets, local
.envfiles. Then redeploy. - Check for abuse. Stripe has request logs per key. Your AI provider has usage dashboards. Look for traffic you didn’t make.
- Clean the history so the old key stops being one search away. GitHub recommends
git-filter-repo. Forks, clones and cached views can still hold it, which is why step 1 comes first. - Turn on push protection so the next one gets blocked before it leaves your machine.
# Remove a file from every commit (git-filter-repo 2.47+)
git-filter-repo --sensitive-data-removal --invert-paths --path .env.local
# Then force-push the rewritten history (coordinate with your team first)
git push --force --mirror originTHE BUILDER’S TAKEAWAYA leaked secret is a lost secret. Rotate it, update your env vars, check the logs, then tidy up git.
Don’t hide it with CSS (and other myths)
In the episode, Aster asks if he can just hide the key with CSS. Claude’s answer is a long, tired “Duuuude.” Here are the other tricks that don’t work either:
- Hiding it with CSS or
display: none. The key is in the HTML or JavaScript, not on the screen. CSS only changes what’s painted. - Base64 or “encrypting” it in the frontend. If the browser can decode it to use it, so can anyone. The decoding code ships right next to it.
- A private repository. That helps with git, but it doesn’t help the bundle. Once you deploy, the key is public on your website.
- Checking the Referer or Origin header. Anyone can send any header from a script. Origin checks are a nice extra, not a lock.
- “It’s only the test key.” Test keys still expose your account structure, and they get swapped for live keys at 2 AM on launch day.
THE BUILDER’S TAKEAWAYThere is no way to keep a secret in code that runs on someone else’s computer. Only the server can keep it.
Make it impossible to leak next time
- GitHub push protection blocks pushes that contain known secret formats before they reach your repository. It’s on by default for your user account’s pushes to public repos; turn it on for your repositories too.
- Run a secret scanner before you push. Gitleaks works as a pre-commit hook or in CI.
- Guard server modules with
server-only, so a mistaken import breaks the build instead of shipping your key. - Use the smallest key that works. Stripe recommends restricted keys (
rk_) with only the permissions you need, plus access policies on live keys. - Tell your AI assistant the rule. Add a line to your project instructions: “Secrets never use NEXT_PUBLIC_ or VITE_. Secret API calls go through a server route.”
- Get a second look before launch. Aster’s free repository audit flags credential patterns and risky code in JS/TS repos, with the file and line it found.
THE BUILDER’S TAKEAWAYMake the safe path the default path: guarded modules, scanners that block bad pushes, and narrow keys.
QUESTIONS PEOPLE ASK
FAQ
Is it safe to put my Stripe publishable key in the frontend?
Yes. Stripe publishable keys (pk_live_ and pk_test_) are designed for Stripe.js and mobile apps. They can create tokens and payment methods but can’t charge cards or read account data. Secret (sk_) and restricted (rk_) keys must stay on the server.
Are NEXT_PUBLIC_ environment variables secret?
No. Next.js inlines every NEXT_PUBLIC_ value into the JavaScript bundle at build time, so every visitor can read it. Only variables without the prefix stay on the server.
Are VITE_ environment variables exposed?
Yes. Vite bundles every VITE_ variable into your client code at build time, and its docs say they should not contain sensitive information such as API keys.
Is the Supabase anon or publishable key safe to expose?
Yes, as long as Row Level Security is enabled with good policies on every table. The secret and service_role keys bypass RLS and must never reach the browser.
How do I hide an API key in a React app?
You can’t hide it inside the React app itself. Move the API call into a server endpoint (a Next.js route handler, serverless function or edge function) that reads the key from a server-side environment variable, and call that endpoint from React.
I deleted my .env file from GitHub. Am I safe?
No. The key is still in your git history and possibly already copied. Rotate or revoke it immediately, update your environment variables, check usage logs, and then clean the history with git-filter-repo.
How do I find exposed API keys in my project?
Search your production build (.next/static or dist) for key prefixes like sk_live, sk- and sb_secret_. Turn on GitHub secret scanning, run Gitleaks, or run a free Aster repository audit to flag credential patterns.
THE BIGGER PICTURE
The takeaway for builders.
AI tools make shipping fast. They also make it easy to put the house key on the front door. One server route, a `.gitignore` that actually ignores, and a habit of rotating anything that leaked will keep your keys where they belong. Then let something take a second look before you launch.
The source notebook 11 links
Go a little deeper. These are the sources linked in this story.
- 01Next.js: environment variablesnextjs.org
- 02Vite: env variables and modesvite.dev
- 03Expo: environment variablesdocs.expo.dev
- 04Stripe: API keysdocs.stripe.com
- 05Supabase: understanding API keyssupabase.com
- 06OpenAI: best practices for API key safetyhelp.openai.com
- 07Firebase: API keys for Firebasefirebase.google.com
- 08Stripe: best practices for secret keysdocs.stripe.com
- 09GitHub: removing sensitive data from a repositorydocs.github.com
- 10GitHub: about push protectiondocs.github.com
- 11Gitleaks on GitHubgithub.com
PUT THE CONTEXT TO WORK
A second look.
Before you ship.
Connect your GitHub repository and see what needs attention. Your first audit is free.
Start my free audit
