• Agents
  • Docs
  • Pricing
  • Blog
Log in
Get started

Security for apps built with AI. Paste a URL, get a report, fix what matters.

Product

  • How it works
  • What we find
  • Pricing
  • Agents
  • MCP Server
  • CLI
  • GitHub Action

Resources

  • Blog
  • Docs
  • FAQ
  • Glossary

Security

  • Supabase Security
  • Next.js Security
  • Lovable Security
  • Cursor Security
  • Bolt Security

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • Imprint
© 2026 Flowpatrol. All rights reserved.
Back to Blog
Security

Your .env Is Showing: Environment Variable Exposure in Vibe-Coded Apps

AI coding tools make shipping fast, but they also make leaking secrets easy. Here's how environment variables end up in your client-side bundle, how to audit your app in five minutes, and how to fix it before someone else finds your keys.

Flowpatrol TeamMar 29, 202611 min read
Your .env Is Showing: Environment Variable Exposure in Vibe-Coded Apps

Your secrets might already be public

You shipped your app. It works. Stripe payments go through, your database connects, your AI features call the right model. Everything looks great.

But here's a question worth five minutes of your time: can anyone who visits your site see your API keys?

If you built with an AI coding tool -- Lovable, Bolt, Cursor, v0, or any of the others -- there's a real chance the answer is yes. Not because these tools are bad. They're incredible for building fast. But they optimize for "make it work," and the line between "works" and "works securely" is thinner than you'd think. Sometimes it's literally a prefix.

This article walks through exactly how environment variables leak in modern web apps, why AI-generated code makes this more likely, and how to audit and fix your app right now.


How environment variables actually work in Next.js

Most vibe-coded apps end up on Next.js (or a framework with similar conventions), so let's start there.

Next.js has a simple rule for environment variables:

  • Variables prefixed with NEXT_PUBLIC_ are inlined into the client-side JavaScript bundle at build time. They're visible to everyone who visits your site.
  • Variables without that prefix are only available on the server. They never reach the browser.

That's it. That one prefix is the difference between a secret that stays on your server and one that's embedded in your JavaScript for anyone to read.

Server-only variables stay protected while NEXT_PUBLIC_ variables are bundled into client-side code

Here's what that looks like in practice:

# .env.local

# This stays on the server -- safe
STRIPE_SECRET_KEY=sk_live_abc123def456

# This gets shipped to every browser -- EXPOSED
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123def456

Both variables are in the same .env file. Both look similar. But one is private and the other is completely public. The build process doesn't warn you. The app works either way. And if the AI that wrote your code used the wrong prefix, you won't notice until someone else does.


Why AI coding tools get this wrong

AI coding assistants are trained to make things work. When you say "add Stripe payments," the AI needs your Stripe key to be accessible wherever the payment code runs. If it puts the payment logic in a client component (which is common -- client components are where user interactions happen), it needs the key available on the client side. The fastest way to do that? Add NEXT_PUBLIC_ to the variable name.

The code works. The payment goes through. The AI did what you asked.

But it also just published your secret key to every visitor's browser.

This is the pattern we see over and over:

The wrong way: secret key in client code

// components/PaymentButton.tsx
"use client";

import Stripe from "stripe";

// This key is now in your JavaScript bundle
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);

export function PaymentButton() {
  const handlePayment = async () => {
    // Direct Stripe API call from the browser
    const session = await stripe.checkout.sessions.create({
      line_items: [{ price: "price_abc123", quantity: 1 }],
      mode: "payment",
      success_url: `${window.location.origin}/success`,
    });
    window.location.href = session.url!;
  };

  return <button onClick={handlePayment}>Buy Now</button>;
}

What's wrong here: the Stripe secret key is prefixed with NEXT_PUBLIC_, making it available in the browser. Anyone can open DevTools, find the key, and use it to issue refunds, create charges, or access your entire Stripe account.

The right way: API route as a proxy

// app/api/checkout/route.ts (server-side only)
import Stripe from "stripe";

// No NEXT_PUBLIC_ prefix -- this stays on the server
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const { priceId } = await request.json();

  const session = await stripe.checkout.sessions.create({
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "payment",
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cancel`,
  });

  return Response.json({ url: session.url });
}
// components/PaymentButton.tsx
"use client";

export function PaymentButton() {
  const handlePayment = async () => {
    // Call YOUR server, not Stripe directly
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId: "price_abc123" }),
    });
    const { url } = await res.json();
    window.location.href = url;
  };

  return <button onClick={handlePayment}>Buy Now</button>;
}

The difference: the secret key never leaves your server. The client component calls your API route, which calls Stripe. The browser never sees sk_live_anything.


It's not just Stripe

This pattern applies to every secret your app uses. Here are the most common ones we see exposed in vibe-coded apps:

SecretWhat an attacker can do with it
STRIPE_SECRET_KEYIssue refunds, create charges, access customer data
OPENAI_API_KEYRun up your bill with unlimited API calls
DATABASE_URLRead, modify, or delete your entire database
SUPABASE_SERVICE_ROLE_KEYBypass all RLS policies, full admin access
RESEND_API_KEY / SENDGRID_API_KEYSend emails as you, phishing campaigns
AWS_SECRET_ACCESS_KEYAccess any AWS service your key has permissions for

Every one of these should be server-only. None of them should ever have NEXT_PUBLIC_ in front of them.


The .env file in your git repo

There's another way secrets leak, and it has nothing to do with build prefixes: committing your .env file to your repository.

When you tell an AI to set up a project, it often creates a .env or .env.local file with placeholder values. You replace them with real keys. Then you commit and push. If .env isn't in your .gitignore, your secrets are now in your git history -- even if you delete the file later.

# Check if .env is being tracked
git ls-files | grep -i env

# Check if .env is in your .gitignore
cat .gitignore | grep -i env

If git ls-files shows your .env file, it's already in your history. Removing it from the current tree isn't enough -- it's still in previous commits.

# What your .gitignore MUST include
.env
.env.local
.env.production
.env*.local

If you find that secrets have been committed, you need to rotate them immediately. Don't just remove the file -- the old values are still in git history. Generate new keys, revoke the old ones, and consider using a tool like git-filter-repo to clean the history if the repo has ever been public.


Platform-specific guidance

Next.js (App Router and Pages Router)

Next.js is clear about the rule: NEXT_PUBLIC_ means "ship to the client." But there are subtleties:

  • Server Components (the default in App Router) run on the server. They can access non-prefixed env vars directly. But if you pass that value as a prop to a client component, it's in the bundle.
  • API Routes (app/api/*/route.ts) run on the server. Use them as proxies for any external API that requires a secret key.
  • Server Actions (the "use server" directive) also run on the server. They're a clean way to handle server-side logic without writing separate API routes.
// app/actions.ts
"use server";

export async function createCheckout(priceId: string) {
  // This runs on the server -- env var is safe here
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

  const session = await stripe.checkout.sessions.create({
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "payment",
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
  });

  return { url: session.url };
}

Vercel

Vercel's environment variable settings have three scopes: Production, Preview, and Development. A common mistake is adding a secret to the "Preview" scope, which makes it available in every pull request preview deployment -- potentially visible to anyone with the preview URL.

Best practices on Vercel:

  • Use Vercel's built-in environment variable UI, not hardcoded .env files
  • Mark sensitive variables as Sensitive (Vercel redacts them in logs)
  • Don't add secrets to Preview environments unless your previews are access-controlled
  • Use different keys for production vs. preview vs. development

Supabase

Supabase has two keys that often cause confusion:

  • anon key: Designed to be public. Safe in client code -- but only if RLS is enabled.
  • service_role key: Full admin access. Bypasses all RLS. Must never be in client code.
# Safe to use with NEXT_PUBLIC_
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...

# NEVER give these a NEXT_PUBLIC_ prefix
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...

If you see NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY anywhere in your code, stop and fix it now. That key lets anyone bypass every security policy in your database.


The five-minute audit

You don't need a security background to check for this. Here's how to audit your app right now:

1. Check your page source (30 seconds)

Open your deployed app in a browser. View the page source (Ctrl+U or Cmd+Option+U). Search for:

  • sk_live or sk_test (Stripe)
  • sk- (OpenAI)
  • key= or apikey= or api_key=
  • service_role
  • postgres:// or mysql://
  • Any string that looks like a long random token

If you find any of these, you have a leaked secret.

2. Check your JavaScript bundle (60 seconds)

Open DevTools (F12), go to the Sources tab (Chrome) or Debugger tab (Firefox). Look in your JavaScript files -- particularly the ones in _next/static/chunks/. Use Ctrl+Shift+F to search across all loaded scripts for the same patterns above.

You can also do this from the command line after building:

# Build your app and search the output
next build
grep -r "sk_live\|sk_test\|sk-\|service_role\|postgres://" .next/static/

If grep returns anything, those secrets are in your client bundle.

3. Check your environment variables (60 seconds)

Open your .env, .env.local, and .env.production files. Every variable with the NEXT_PUBLIC_ prefix should be something you're comfortable being public. Go through them one by one:

# List all NEXT_PUBLIC_ variables
grep "NEXT_PUBLIC_" .env*

Ask yourself for each one: "Would I be okay posting this value on Twitter?" If the answer is no, remove the NEXT_PUBLIC_ prefix and move the usage to server-side code.

4. Check your git history (60 seconds)

# Check if any .env files are tracked
git ls-files | grep -i "\.env"

# Search git history for common secret patterns
git log -p --all -S "sk_live" --oneline
git log -p --all -S "sk-" --oneline
git log -p --all -S "service_role" --oneline

If any of these return results, those secrets have been in your repo at some point.

5. Check your .gitignore (30 seconds)

# Make sure .env files are ignored
cat .gitignore

Verify that .env, .env.local, .env.production, and .env*.local are all listed. If they're not, add them now.


What to do if you find a leak

If the audit turned up exposed secrets, here's the order of operations:

  1. Rotate the key immediately. Go to each service's dashboard (Stripe, OpenAI, Supabase, etc.) and generate a new key. Revoke the old one.
  2. Fix the code. Remove the NEXT_PUBLIC_ prefix from any secret that shouldn't be public. Move the usage to a server-side API route or Server Action.
  3. Update your deployment. Push the new code, update environment variables on Vercel/Netlify/wherever, and redeploy.
  4. Check for damage. Review your Stripe dashboard for unauthorized charges. Check your OpenAI usage for unexpected spikes. Look at your database logs for unusual queries.
  5. Clean your git history if secrets were committed to a public repo. Use git-filter-repo to remove them from previous commits.

The whole process takes fifteen to thirty minutes. The cost of not doing it could be thousands of dollars in fraudulent charges or a full data breach.


A rule of thumb that saves you

When an AI coding tool generates code that uses an environment variable, ask one question:

"Does the browser need to know this value?"

If the answer is yes (like your app's public URL or a Supabase anon key), NEXT_PUBLIC_ is correct.

If the answer is no (like a secret API key, a database connection string, or a service role key), the variable should stay server-only. If the AI put the usage in a client component, move it to an API route or Server Action.

That single question catches the vast majority of env exposure issues.


Keep shipping, but check your keys

AI coding tools are changing what's possible. You can go from idea to working product in hours. That speed is real, and it's worth celebrating.

But speed also means the gap between "deployed" and "deployed securely" can be easy to miss. Checking your environment variables isn't a burden -- it's a five-minute step that protects everything you built.

Flowpatrol scans for exposed environment variables, leaked API keys, and the other security gaps that AI coding tools tend to create. If you'd rather automate the audit than do it manually, give it a try. Paste your URL, get a report, fix what matters.

Your app deserves to be out there. Make sure your secrets aren't.


Have questions about securing your vibe-coded app? Find us on Twitter/X or join the conversation on r/webdev and r/nextjs.

Back to all posts

More in Security

IDOR: The Vulnerability AI Can't See
Mar 29, 2026

IDOR: The Vulnerability AI Can't See

Read more
The OWASP Top 10 Through the Lens of AI-Generated Code
Mar 29, 2026

The OWASP Top 10 Through the Lens of AI-Generated Code

Read more
SQL Injection Is Not Dead: How It Shows Up in AI-Generated Code
Mar 29, 2026

SQL Injection Is Not Dead: How It Shows Up in AI-Generated Code

Read more
JWT_SECRETForge authentication tokens for any user