
Your Vercel App Is Leaking Secrets: How NEXT_PUBLIC_ Turns API Keys Into Public Data
Your Vercel App Is Leaking Secrets: How NEXT_PUBLIC_ Turns API Keys Into Public Data
There is a class of security vulnerability so common in Next.js applications that we see it in roughly 40% of the Vercel-deployed apps that PreBreach scans. It is not a sophisticated exploit. It is not a zero-day. It is developers accidentally putting secret API keys behind the NEXT_PUBLIC_ prefix, which embeds them directly into the JavaScript bundle that ships to every visitor's browser.
If you have ever added a NEXT_PUBLIC_ environment variable without fully understanding what that prefix does, this post is for you.
How Next.js Environment Variables Actually Work
Next.js has a clear but frequently misunderstood system for environment variables. The rules are simple:
Server-only variables (no prefix): Available in Server Components, API routes, getServerSideProps, and middleware. Never sent to the browser. This is where secrets belong.
DATABASE_URL=postgresql://user:password@host:5432/db
STRIPE_SECRET_KEY=sk_live_abc123
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
Client-accessible variables (NEXT_PUBLIC_ prefix): Inlined into the JavaScript bundle at build time. Visible to anyone who visits your site. This is for truly public values only.
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_abc123
The critical word here is inlined at build time. When Next.js builds your application, it literally replaces every instance of process.env.NEXT_PUBLIC_* in your client-side code with the actual string value. This value is then permanently baked into the JavaScript files served to users. It does not matter if you later change or delete the environment variable — the value is already in the deployed bundle.
The Mistakes We See Every Week
Mistake 1: Supabase service_role Key With NEXT_PUBLIC_
This is the most dangerous and most common leak we find. Supabase provides two keys:
anonkey: Designed to be public. Respects Row Level Security (RLS) policies.service_rolekey: Bypasses all RLS. Full admin access to every table.
The mistake:
# .env.local — WRONG
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// lib/supabase.ts — used in a Client Component
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // Admin key in the browser
);
What an attacker can do: With the service_role key, an attacker can read, modify, and delete every row in every table in your database, regardless of RLS policies. They can create admin accounts, exfiltrate user data, and wipe your entire database. They do not even need to write an exploit — they just use the Supabase client library with your key.
Mistake 2: Stripe Secret Key Exposed
# .env.local — WRONG
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51ABC123...
// components/PricingPage.tsx — Client Component
'use client';
import Stripe from 'stripe';
// This creates a Stripe instance with your secret key in the browser
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
What an attacker can do: With your Stripe secret key, an attacker can issue refunds, view all customer payment data, create charges, modify subscriptions, and access your complete financial history. Stripe explicitly warns that secret keys must never be exposed client-side.
Mistake 3: Database Connection Strings
# .env.local — WRONG
NEXT_PUBLIC_DATABASE_URL=postgresql://admin:mypassword@db.example.com:5432/production
This sometimes happens when developers copy-paste environment variables into Vercel's dashboard and add the NEXT_PUBLIC_ prefix thinking it is required for the variable to work. It is not. The prefix should only be used for values that must be accessible in client-side code.
What an attacker can do: Direct database access. Full read and write to every table. Game over.
Mistake 4: Third-Party API Keys That Should Be Server-Only
# .env.local — WRONG
NEXT_PUBLIC_OPENAI_API_KEY=sk-abc123...
NEXT_PUBLIC_SENDGRID_API_KEY=SG.abc123...
NEXT_PUBLIC_AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI...
Developers often add the NEXT_PUBLIC_ prefix because their code runs in a Client Component and the variable is undefined without it. The correct fix is to move the code to the server — not to expose the secret to the browser.
How to Check If You Are Leaking Secrets
Method 1: View Page Source
Open your deployed application in a browser, right-click, and select "View Page Source." Search for key prefixes:
sk_liveorsk_test(Stripe secret keys)eyJhbGciOi(base64-encoded JWTs, including Supabase keys)SG.(SendGrid API keys)sk-(OpenAI API keys)postgresql://ormysql://(database URLs)AKIA(AWS access key IDs)
Method 2: Check NEXT_DATA
Next.js serializes data into a __NEXT_DATA__ script tag on pages that use getServerSideProps or getStaticProps. Open your browser's developer tools, go to the Elements tab, and search for __NEXT_DATA__. Inspect the JSON for any sensitive values that might have been passed as props.
// In your browser console:
console.log(JSON.stringify(__NEXT_DATA__, null, 2));
Look for anything that should not be public: API keys, internal IDs, admin flags, or user data belonging to other users.
Method 3: Inspect JavaScript Bundles
Your Next.js application's JavaScript bundles contain every NEXT_PUBLIC_ value. You can inspect them directly:
- Open browser developer tools
- Go to the Sources or Network tab
- Look at the JavaScript files under
_next/static/chunks/ - Search for key prefixes (
sk_live,service_role, etc.)
You can also do this from the command line after building:
# Build your app and search the output
npm run build
grep -r "sk_live\|service_role\|sk-\|SG\." .next/static/chunks/
Method 4: Check Your Environment Variable Configuration
Review your .env files and Vercel dashboard:
# List all NEXT_PUBLIC_ variables in your project
grep -r "NEXT_PUBLIC_" .env* --include=".env*"
For each NEXT_PUBLIC_ variable, ask: "Is it safe for every visitor to my website to see this value?" If the answer is no, it should not have the NEXT_PUBLIC_ prefix.
The Fix: Server-Only Patterns
The solution is straightforward: keep secrets on the server and access them through server-side code paths.
Pattern 1: Server Components (App Router)
Server Components are the default in the Next.js App Router. They run only on the server and never ship JavaScript to the browser:
// app/dashboard/page.tsx — Server Component (default)
import { createClient } from '@supabase/supabase-js';
// This runs on the server. The service_role key never reaches the browser.
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export default async function DashboardPage() {
const { data: users } = await supabase.from('users').select('*');
return (
<div>
{users?.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Pattern 2: API Routes
When client-side code needs data that requires a secret to fetch, create an API route as a proxy:
// app/api/create-checkout/route.ts
import Stripe from 'stripe';
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({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return Response.json({ url: session.url });
}
// components/PricingButton.tsx — Client Component
'use client';
export function PricingButton({ priceId }: { priceId: string }) {
const handleClick = async () => {
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await response.json();
window.location.href = url;
};
return <button onClick={handleClick}>Subscribe</button>;
}
Pattern 3: Server Actions
Server Actions let you call server-side functions directly from Client Components without creating a separate API route:
// app/actions.ts
'use server';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function deleteUser(userId: string) {
// Verify the requesting user has permission first
const session = await getServerSession();
if (session?.user?.role !== 'admin') {
throw new Error('Unauthorized');
}
await supabase.from('users').delete().eq('id', userId);
}
Pattern 4: The server-only Package
For an extra layer of protection, use the server-only package to cause a build error if server code is accidentally imported into a Client Component:
npm install server-only
// lib/supabase-admin.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
// If any Client Component imports this file, the build will fail
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
This turns a silent runtime leak into a loud build-time error. It is the single most effective guard against accidentally exposing server-only code.
Environment Variable Best Practices for Vercel
Use Vercel's Environment Variable Scoping
Vercel lets you set environment variables per environment (Production, Preview, Development). Use this:
- Production: Only the secrets needed in production
- Preview: Test/staging keys (never production secrets in preview deployments)
- Development: Your local development keys
Naming Convention
Establish a clear convention that makes it obvious which variables are public:
# Server-only (no prefix)
DATABASE_URL=...
STRIPE_SECRET_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
OPENAI_API_KEY=...
# Client-safe (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_APP_URL=...
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=...
NEXT_PUBLIC_POSTHOG_KEY=...
The rule is simple: if the key name contains SECRET, SERVICE_ROLE, PRIVATE, or PASSWORD, it should never have the NEXT_PUBLIC_ prefix.
Audit Your .env Files Regularly
Add a pre-commit hook or CI check that flags suspicious NEXT_PUBLIC_ variables:
#!/bin/bash
# .husky/pre-commit or CI script
SUSPICIOUS=$(grep -rn "NEXT_PUBLIC_.*\(SECRET\|SERVICE_ROLE\|PRIVATE\|PASSWORD\|DATABASE_URL\)" .env* 2>/dev/null)
if [ -n "$SUSPICIOUS" ]; then
echo "WARNING: Potentially sensitive variables with NEXT_PUBLIC_ prefix:"
echo "$SUSPICIOUS"
exit 1
fi
What to Do If You Have Already Leaked a Secret
If you discover that a secret has been exposed in a deployed build:
- Rotate the secret immediately. Generate a new key in the affected service's dashboard. The old key is compromised — assume it has been captured.
- Update your environment variables in Vercel and redeploy.
- Check for unauthorized usage. Review logs in the affected service (Supabase, Stripe, etc.) for unusual activity during the exposure window.
- Remove the NEXT_PUBLIC_ prefix from the variable and refactor your code to access it server-side.
- Audit your other variables while you are at it. If one was wrong, others might be too.
Note that simply removing the variable and redeploying is not enough. Previous builds may be cached, and the secret was visible to anyone who loaded your site during the exposure window. Rotation is mandatory.
PreBreach Detects This Automatically
This is exactly the kind of vulnerability that PreBreach's custom Nuclei templates are designed to find. Our scan checks for:
- Exposed environment variables in JavaScript bundles
- Supabase service_role keys in client-side code
- Stripe secret keys in page source
- Database connection strings in
__NEXT_DATA__ - Other common secret patterns in publicly accessible assets
Our 8 AI agents, powered by Claude Opus, do not just pattern-match — they understand the context of Next.js applications and can identify secrets that generic scanners miss. The multi-model validation using GPT cross-checks ensures findings are accurate, not false positives.
PreBreach plans start at $29/month and scans take 30-60 minutes. Each scan covers this and dozens of other vulnerabilities specific to modern stacks.
Check your Next.js app now — run a PreBreach scan and find out what your browser is telling the world.

