All articles

How Exposed API Keys End Up in Your JavaScript Bundle

How Exposed API Keys End Up in Your JavaScript Bundle

API keys bundled into client-side JavaScript are the #1 critical finding in vibe-coded apps. How it happens and how to fix it.

March 5, 2026VibeShield Team9 min read

API key exposure is one of the most critical and common findings VibeShield detects in vibe-coded applications. In our scans, over 60% of apps have at least one secret visible in their JavaScript bundle.

The 60-Second Timeline of a Stolen Key

Here is what happens in the first hour after you accidentally ship an API key in your JavaScript bundle.

T+0:00 — Your Vercel deployment goes live. The production JS bundle is now public at /_next/static/chunks/main-abc123.js.

T+0:02 — Your app starts appearing in Vercel's deployment feed and on social media if you tweeted about it.

T+0:15 — Automated bots operated by crypto mining groups and AI abuse networks crawl new deployments continuously. They fetch your JS bundle and run regex patterns across it. Your sk-proj- key matches. It's captured, validated against the OpenAI API, and added to a shared pool.

T+0:30 — The key is actively being used to generate GPT-4o completions at scale. The requests come from dozens of IPs across multiple regions.

T+1:00 — You've been charged $47 in API usage. The bot is still running.

T+6:00 — You wake up to an email from OpenAI about unusual usage patterns. You've been charged $340.

T+24:00 — If you don't rotate the key, total charges can reach thousands of dollars. Some developers have reported $50,000+ bills from a single leaked OpenAI key left active for a few days.

This timeline is not hypothetical. It reflects the actual behavior of the key-harvesting ecosystem in 2026. Automated scanning of public JavaScript bundles is fully industrialized. The moment your key is publicly accessible, it is effectively compromised.

How Keys End Up in Bundles

Pattern 1: Direct Client Initialization

The most common pattern: an AI assistant generates working code that initializes a service client directly in the frontend:

// frontend/src/lib/ai.ts — generated by Cursor
import OpenAI from 'openai';
 
export const openai = new OpenAI({
  apiKey: process.env.NEXT_PUBLIC_OPENAI_KEY,
  dangerouslyAllowBrowser: true,
});

Note the NEXT_PUBLIC_ prefix — in Next.js, any variable starting with NEXT_PUBLIC_ is intentionally embedded in the client bundle. The dangerouslyAllowBrowser: true flag is the OpenAI SDK's way of telling you: "you're about to expose this key."

Pattern 2: Wrong Environment Variable Prefix

# .env — the wrong way
NEXT_PUBLIC_OPENAI_KEY=sk-proj-abc123...    # bundled to client ❌
NEXT_PUBLIC_STRIPE_SECRET=sk_live_xyz789... # bundled to client ❌
 
# The right way
OPENAI_API_KEY=sk-proj-abc123...            # server-only ✅
STRIPE_SECRET_KEY=sk_live_xyz789...         # server-only ✅

AI assistants suggest NEXT_PUBLIC_ variables because they "just work" without requiring an API route. The tradeoff — public exposure — is often not mentioned.

Pattern 3: Inline During Prototyping

During rapid prototyping, keys often get hardcoded directly and accidentally committed:

// "I'll replace this later" — but it ships
const stripe = new Stripe('sk_live_abc123xyz...');
const anthropic = new Anthropic({ apiKey: 'sk-ant-api03-...' });
const twilio = require('twilio')('ACabcdef123456', 'auth_token_here');

Pattern 4: Git History Exposure

You removed the key from the code, but it's still in your git history. This is one of the most underappreciated exposure vectors.

# Find secrets in git history
git log --all --full-history -- '**/.env*'
git log -p --all | grep -E "sk-proj-|sk_live_|AKIA|sk-ant-"
 
# Or use git-secrets to scan history
git secrets --scan-history

When you push to a public GitHub repository, the entire git history is public. Secret scanning tools (including GitHub's own) will find keys in history even if they were removed in a later commit. Treat any key that ever appeared in a commit as compromised, regardless of whether it's still in the current codebase.

The fix for history exposure:

# Option 1: BFG Repo Cleaner (faster than filter-branch)
java -jar bfg.jar --replace-text secrets.txt my-repo.git
git reflog expire --expire=now --all && git gc --prune=now --aggressive
git push --force
 
# Option 2: git filter-repo (modern approach)
git filter-repo --path .env --invert-paths

But remember: if the repository was ever public or cloned by others, the history may already be cached by GitHub, GitLab, or other mirrors. Always rotate the key immediately — don't rely on history rewriting alone.

Pattern 5: Environment Variable Misconfiguration in CI/CD

GitHub Actions can accidentally print secrets to logs when error handling is careless:

# DANGEROUS: prints all environment variables including secrets
- name: Debug environment
  run: env
 
# DANGEROUS: shell expansion can leak values
- name: Build
  run: echo "Using API key: $OPENAI_API_KEY"
 
# DANGEROUS: verbose output from tools
- name: Deploy
  run: vercel --token $VERCEL_TOKEN --debug  # --debug may print env vars

GitHub Actions masks known secrets in logs, but only secrets that are explicitly registered as repository secrets. If a key is in a .env file that gets loaded during CI, or passed as a plain environment variable rather than a secret, it may appear in logs.

# Safe pattern: use GitHub Secrets, never inline values
- name: Build and deploy
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
  run: npm run build

Common Key Patterns by Service

VibeShield scans for over 100 key patterns. Here are the most critical ones to watch for:

| Service | Pattern | Risk Level | |---------|---------|------------| | OpenAI | sk-proj-[A-Za-z0-9]{48} | Critical — billing abuse | | Anthropic/Claude | sk-ant-api03-[A-Za-z0-9]{95} | Critical — billing abuse | | Stripe (live) | sk_live_[A-Za-z0-9]{24} | Critical — financial fraud | | AWS IAM | AKIA[A-Z0-9]{16} | Critical — cloud takeover | | Supabase service_role | eyJ (JWT) with service_role claim | Critical — full DB access | | GitHub PAT | ghp_[A-Za-z0-9]{36} | High — repo access | | Twilio | SK[a-z0-9]{32} + account SID | High — SMS/call fraud | | SendGrid | SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43} | High — email spam | | Mapbox | pk\.eyJ1 (public — safe) | Low (public by design) | | Google API | AIza[0-9A-Za-z_-]{35} | Medium — quota abuse |

Note that Mapbox public keys are intentionally client-side — don't rotate these when you find them in bundles. The important distinction is pk. (public key, safe) vs sk. (secret key, dangerous).

What Attackers Do with Exposed Keys

  • OpenAI keys: Free GPT-4 access, bills of $10,000+ before you notice. Some attackers resell API access.
  • Stripe secret keys: Enumerate customers, issue refunds, create fake subscriptions, access full payment history.
  • AWS credentials: Spin up EC2 instances for crypto mining, access S3 buckets, read SSM Parameter Store secrets.
  • Supabase service_role: Bypasses all Row-Level Security, giving full database read/write/delete access.
  • GitHub PATs: Clone private repositories, read secrets from Actions, potentially pivot to other services.
  • Twilio keys: Send SMS spam at your expense, make phone calls, access your customer's phone numbers.

Incident Response Checklist

If VibeShield (or anyone else) finds an exposed key in your app, follow this sequence immediately:

1. Rotate and revoke the key (first priority)

# Don't spend time investigating first — rotate immediately
# OpenAI: dashboard.openai.com/api-keys
# Stripe: dashboard.stripe.com/apikeys
# AWS: IAM console → Security credentials
# Supabase: Project settings → API → Roll anon/service_role key

2. Audit usage logs

# OpenAI — check for unusual usage
# Stripe — look for fraudulent refunds or subscriptions
# AWS CloudTrail — identify any unauthorized API calls
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=COMPROMISED_USER \
  --start-time "2026-01-01" \
  --query 'Events[*].{Time:EventTime,Event:EventName,IP:SourceIPAddress}'

3. Check for downstream compromise If an AWS key was exposed, assume all resources accessible to that IAM role may have been accessed. Check S3 bucket access logs, RDS audit logs, and any other services that key could reach.

4. Remove from git history (see Pattern 4 above)

5. Add pre-commit scanning (see Prevention section below)

6. Review your billing for all affected services. File disputes if fraudulent charges appear.

Prevention: Pre-Commit Hooks with gitleaks

Install gitleaks to catch secrets before they're committed:

# Install gitleaks
brew install gitleaks  # macOS
# or: go install github.com/gitleaks/gitleaks/v8@latest
 
# Scan your current repo
gitleaks detect --source . --verbose
 
# Install as a pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
gitleaks protect --staged --verbose
if [ $? -ne 0 ]; then
  echo "❌ Secret detected! Commit blocked."
  exit 1
fi
EOF
chmod +x .git/hooks/pre-commit

Or use the pre-commit framework for team-wide enforcement:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
pip install pre-commit
pre-commit install

Prevention: CI/CD Secret Scanning

GitHub Advanced Security (free for public repos):

Enable secret scanning in your repository settings. GitHub automatically scans every push and pull request for known secret patterns. When a secret is detected, GitHub notifies you immediately and (for some providers) can automatically revoke the key via a partnership with the service.

GitLab Secret Detection:

# .gitlab-ci.yml
include:
  - template: Security/Secret-Detection.gitlab-ci.yml
 
secret_detection:
  variables:
    SECRET_DETECTION_HISTORIC_SCAN: "true"  # scan full history

In a GitHub Actions workflow:

- name: Secret scan
  uses: gitleaks/gitleaks-action@v2
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

Rate Limiting as a Defense-in-Depth

Even if a key leaks, rate limiting reduces the blast radius. Configure per-key rate limits on the services you use:

// Middleware rate limiting on your API proxy
// app/api/generate/route.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "1 m"),  // 10 requests per minute per IP
});
 
export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";
  const { success, remaining } = await ratelimit.limit(ip);
 
  if (!success) {
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": "60" }
    });
  }
 
  // proceed with OpenAI call
}

Rate limiting on your proxy doesn't prevent the key from being abused if someone bypasses your proxy and calls OpenAI directly. But it means that even if your proxy is abused, the damage is bounded.

For a broader view of how this fits into overall app security, see Top 5 Vulnerabilities in AI-Generated Apps and Cursor AI Security Flaws.

The Fix: Move Keys to the Backend

The correct architecture is a thin API proxy:

// app/api/generate/route.ts (server-side)
import { auth } from "@/auth";
 
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,  // server-only, never bundled
});
 
export async function POST(req: Request) {
  // 1. Authenticate the user
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });
 
  // 2. Rate limit by user ID
  const { success } = await ratelimit.limit(session.user.id);
  if (!success) return new Response("Too many requests", { status: 429 });
 
  // 3. Validate input
  const { prompt } = await req.json();
  if (!prompt || typeof prompt !== "string") {
    return new Response("Invalid input", { status: 400 });
  }
 
  // 4. Call the API server-side
  const result = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: prompt }],
    max_tokens: 500,  // bound your costs
  });
 
  return Response.json(result);
}

The frontend calls /api/generate — it never sees the OpenAI key, can't bypass rate limits, and can't make unbounded requests.

Auditing Your Bundle

To check your own bundle for exposed secrets:

# Build and search the output
npm run build
grep -r "sk-proj-\|sk_live_\|AKIA\|service_role\|sk-ant-\|ghp_" .next/static/
 
# Check the deployed version
curl https://yourapp.com/_next/static/chunks/main.js | \
  grep -oE 'sk-proj-[A-Za-z0-9_-]{40,}'
 
# Use truffleHog for thorough scanning
docker run --rm trufflesecurity/trufflehog:latest \
  filesystem --directory=/path/to/project --json

FAQ

My key was only exposed for 2 hours. Do I still need to rotate it?

Yes, immediately. Automated scanners find keys within minutes of deployment. Two hours is more than enough time for a key to be harvested and used. Treat any exposed key as fully compromised from the moment it went live.

Are NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY safe to expose?

Yes, these are designed to be public. The anon key only grants access based on your RLS policies (which should require authentication). The URL is just a hostname. Neither is a secret. In contrast, SUPABASE_SERVICE_ROLE_KEY is never safe to expose.

My .env file is in .gitignore. Am I safe?

From git exposure, yes — but there are other risks. If your build process inlines environment variables into the bundle (e.g., via NEXT_PUBLIC_ prefix), the key ends up in the bundle regardless of .gitignore. And if you accidentally commit .env once, it's in the history forever.

How do I prevent AI tools from suggesting NEXT_PUBLIC_ prefixes for secrets?

Add explicit instructions to your AI assistant's system prompt or project context: "Never use NEXT_PUBLIC_ prefix for OpenAI, Stripe, or any API keys that must remain server-side. All external API calls must go through Next.js API routes." This significantly reduces the frequency of the pattern, though code review is still necessary.


VibeShield scans your deployed JavaScript bundles for 100+ secret patterns on every scan. If you have exposed keys, you'll know within minutes.

Scan your app for exposed secrets →

Free security scan

Test your app for these vulnerabilities

VibeShield automatically scans for everything covered in this article and more — 18 security checks in under 3 minutes.

Scan your app free