Your first GitHub repository audit is free.No card. Every finding included.
aster

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.

Security Guide10 min readBy Aster
Voxel Aster looking shocked next to Claude while a glowing gold key sits in a browser window with a red warning sign. Headline: Your key is public?

Commented KEYS? Here’s everything we promised: the checklist, the code, and why each step matters.

Jump to a story
  1. Watch the reel
  2. The checklist
  3. 01Why your secret key ends up in the browser
  4. 02Which keys are safe in the browser, and which never are
  5. 03Keep the key on the server, with copy-paste code
  6. 04.env and .gitignore, done right
  7. 05Already pushed it to GitHub? Rotate first
  8. 06Don’t hide it with CSS (and other myths)
  9. 07Make it impossible to leak next time
  10. FAQ
  11. 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, 2026
YouTube loads only when you press play.
01

Why your secret key ends up in the browser

NEXT_PUBLIC_inlined into the JS bundle
VITE_bundled into client code
2 minto find it in DevTools

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_.

Don’t.env.local
# Shipped to every visitor's browser
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51Hx...
NEXT_PUBLIC_OPENAI_API_KEY=sk-proj-...
Do.env.local
# 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:

terminal
# 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 | head

No 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.

02

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.

ServiceSafe in the browserServer only
Stripepk_live_… / pk_test_… publishable keysk_… secret keys and rk_… restricted keys
SupabasePublishable key (sb_publishable_…) or legacy anon key, only with RLS onSecret key (sb_secret_…) or legacy service_role. They bypass Row Level Security
OpenAI, Anthropic and other LLM APIsNothingEvery API key. Proxy requests through your server
Firebase (web config)apiKey in the web config identifies the project; your Security Rules protect the dataService account JSON and Admin SDK credentials
Your own backendShort-lived user session tokensDatabase 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.

03

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

lib/stripe.ts
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

app/api/checkout/route.ts
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

app/pricing/BuyButton.tsx
'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:

app/api/chat/route.ts
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.

04

.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.

.gitignore
# local env files
.env
.env*.local
.env.development
.env.production

# but keep the template
!.env.example
.env.example
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_replace_me
STRIPE_SECRET_KEY=sk_test_replace_me
OPENAI_API_KEY=replace_me

Adding a file to .gitignore doesn’t untrack it if git already knows it. Check, and untrack it if needed:

terminal
# 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).

05

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.

  1. 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.
  2. Update every place that used it: your host’s environment variables, CI secrets, local .env files. Then redeploy.
  3. Check for abuse. Stripe has request logs per key. Your AI provider has usage dashboards. Look for traffic you didn’t make.
  4. 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.
  5. Turn on push protection so the next one gets blocked before it leaves your machine.
terminal
# 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 origin

THE BUILDER’S TAKEAWAYA leaked secret is a lost secret. Rotate it, update your env vars, check the logs, then tidy up git.

06

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.

07

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.

  1. 01Next.js: environment variablesnextjs.org
  2. 02Vite: env variables and modesvite.dev
  3. 03Expo: environment variablesdocs.expo.dev
  4. 04Stripe: API keysdocs.stripe.com
  5. 05Supabase: understanding API keyssupabase.com
  6. 06OpenAI: best practices for API key safetyhelp.openai.com
  7. 07Firebase: API keys for Firebasefirebase.google.com
  8. 08Stripe: best practices for secret keysdocs.stripe.com
  9. 09GitHub: removing sensitive data from a repositorydocs.github.com
  10. 10GitHub: about push protectiondocs.github.com
  11. 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

KEEP THE CURIOSITY GOING

Your next read.

YOUR FEED, WITH A LITTLE MORE CONTEXT

See it. Save it.
Build something better.

Catch the short version on your favorite platform.
Come back here for the details and sources.

Aster

Stay secure.
Ask away.

Hi, I’m Aster. I can help with your first audit, plans, or connecting GitHub.

Prepared site guidance