Vercel Preview Deployments Are Leaking Secrets. Audit Yours Now.
Preview URLs are treated like staging by developers and like production by attackers. Here's how we found real secrets exposed across three client accounts, and the guardrails we now enforce by default.

A client asked us to look at a slow build pipeline. Two hours in we weren't looking at builds anymore — we were looking at a public preview URL that returned a JSON payload containing a Stripe live key, an internal admin token, and the full list of feature flags for their next product launch. Nothing was breached. But nothing was protecting it either.
This has happened on three separate audits in the last eight months. Different teams, different stacks, same shape of mistake. If you ship Next.js on Vercel and you haven't specifically hardened preview deployments, this article is worth twenty minutes.
Why preview deployments are the soft underbelly
Preview deployments feel ephemeral. They get a random-looking URL like myapp-git-feature-xyz-team.vercel.app, they live for a pull request, and most engineers mentally file them next to localhost. That mental model is wrong in three ways.
First, preview URLs are public by default unless you turn on Vercel's Deployment Protection. Anyone with the URL can hit them. And those URLs leak — into Slack, Linear, Jira, GitHub PR comments, Loom recordings, and the browser history of contractors who left six months ago.
Second, preview environments usually get real credentials. Not always production, but often staging credentials that point at databases containing real customer PII, or third-party sandbox keys that still cost money if abused. In one audit, the "staging" Twilio key was rate-limited but attached to a paid account with no spending cap.
Third, and this is the one that bites hardest: Next.js API routes and Server Actions run on preview deployments with the same code paths as production. If you have a debug endpoint gated on process.env.NODE_ENV !== 'production', congratulations, it's live on every PR.
The NODE_ENV trap
Here's a pattern we see constantly:
// app/api/debug/route.ts
export async function GET() {
if (process.env.NODE_ENV === 'production') {
return new Response('Not found', { status: 404 });
}
return Response.json({
env: process.env,
user: await getCurrentUser(),
flags: await getAllFeatureFlags(),
});
}
On Vercel, NODE_ENV is production for both production and preview deployments. That's a build-time optimization thing — React and Next.js expect it. So this check does the opposite of what the author thought. The debug route is live everywhere except local dev.
The correct gate is VERCEL_ENV, which is production, preview, or development:
if (process.env.VERCEL_ENV !== 'development') {
return new Response('Not found', { status: 404 });
}
Better yet, don't ship debug routes at all. But if you must, gate them on both VERCEL_ENV and a signed header.
What we actually found in three audits
Redacted, but real:
- A
/api/healthendpoint that dumped the full connection string of the Postgres primary, because someone added it to "help debugging" during an incident and never removed it. - A Sentry DSN for the production project embedded client-side on preview builds, which meant every PR was polluting the production Sentry issue stream with noise from half-broken feature branches. Not a leak exactly, but it destroyed the signal-to-noise ratio and cost real money in event quota.
- An admin panel reachable at
/adminon preview URLs, protected only by anNEXT_PUBLIC_ADMIN_MODEflag. The flag was set totrueon preview so QA could test. The URL was in a public Loom. - A
.env.previewfile committed to the repo because someone wanted preview builds to "just work" for external designers. It contained a GitHub personal access token withreposcope. - Server Actions that accepted a
userIdparameter and returned that user's data without an auth check, because "this is only used from the dashboard." The dashboard didn't exist yet on preview, but the Server Action endpoint did.
None of these were sophisticated. All of them would have shown up in a thirty-minute review if anyone had done one.
The audit checklist we now run
We run this on every new engagement and every quarter on retainer clients. It takes about half a day.
1. Enumerate every environment variable, per environment
Vercel's dashboard shows env vars scoped to Production, Preview, and Development. Export them and diff. Any variable that has the same value across production and preview should be treated as production-sensitive. That includes analytics keys, Sentry DSNs, and third-party API keys.
Use the CLI:
vercel env pull .env.production --environment=production
vercel env pull .env.preview --environment=preview
diff <(sort .env.production) <(sort .env.preview)
Anything identical is a candidate for a separate preview-scoped credential.
2. Turn on Deployment Protection
This is a paid feature on Pro and Enterprise, but for anything touching customer data it's non-negotiable. The options are Vercel Authentication (SSO through Vercel), Password Protection, or Trusted IPs. We default to Vercel Authentication with team-only access, plus a shareable Protection Bypass token for external reviewers that rotates weekly.
3. Grep the codebase for environment gates
rg "NODE_ENV.*production" --type ts --type tsx
rg "VERCEL_ENV" --type ts --type tsx
rg "NEXT_PUBLIC_" --type ts --type tsx
Every hit needs a human read. The NEXT_PUBLIC_ grep matters because those values are inlined into the client bundle at build time — if a preview build has a NEXT_PUBLIC_API_URL pointing at production, that URL is now in JavaScript that anyone can download.
4. Check your Server Actions and Route Handlers for auth
Server Actions are the easiest thing to forget to authenticate because they feel like function calls. They're not. They're POST endpoints. Every one of them needs the same auth check you'd put on a REST route.
5. Look at what your preview deployments call
Run a preview build, open the network tab, and look at every outbound request. If preview is hitting your production database, production Redis, or production Stripe, you have a blast radius problem. Preview should hit preview infra, full stop. Yes, that costs more. It costs less than a breach notification.
Guardrails in IaC
Once you've cleaned up, keep it clean. We push as much of this as possible into Terraform using the Vercel provider, so drift shows up in PRs instead of dashboards.
resource "vercel_project" "app" {
name = "client-app"
framework = "nextjs"
vercel_authentication = {
deployment_type = "preview"
}
git_repository = {
type = "github"
repo = "client/app"
}
}
resource "vercel_project_environment_variable" "database_url_preview" {
project_id = vercel_project.app.id
key = "DATABASE_URL"
value = var.preview_database_url
target = ["preview"]
sensitive = true
}
resource "vercel_project_environment_variable" "database_url_prod" {
project_id = vercel_project.app.id
key = "DATABASE_URL"
value = var.production_database_url
target = ["production"]
sensitive = true
}
Separate resources per environment. It's verbose and it's on purpose — you cannot accidentally point preview at prod without a visible diff.
Bonus: a preflight check in CI
We run a GitHub Actions job on every PR that fails the build if certain patterns show up in the deployed bundle:
- name: Check for leaked prod URLs
run: |
if grep -r "api.production.example.com" .next/; then
echo "Production API URL found in preview bundle"
exit 1
fi
Crude, but it's caught real regressions.
The tradeoff we're honest about
Doing this properly costs money and time. Deployment Protection is a paid feature. Separate preview databases mean another RDS instance or another Neon branch. Real auth on Server Actions means writing more code. External QA gets slower because they need Bypass tokens.
We've had clients push back on all of it. The answer we give them: the cost of doing this is measurable in dollars per month. The cost of not doing this is measurable in regulatory fines, customer trust, and the six-week distraction of an incident response. Pick your pain.
Where we'd start
If you have an hour: run the vercel env pull diff and grep for NODE_ENV.*production in your codebase. Fix what you find.
If you have a day: turn on Deployment Protection, split preview credentials from production, and audit every Server Action for auth.
If you're starting a new project: put the Vercel config in Terraform from day one, and never let a preview deployment share a credential with production. Ever.
If you want a second set of eyes on this, our DevOps and cloud team does these audits as a fixed-scope engagement. Or read our earlier post on Vercel Edge Middleware cold starts for another corner of the same platform we've had to fight.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

AWS NAT Gateway Bills Ate Our Margins. Here's How We Cut Them 78%.
A single misconfigured VPC route turned our NAT Gateway into a five-figure monthly line item. Here's the audit trail, the fixes, and what we'd do differently.

GCP Cloud Run vs AWS Lambda for Bursty APIs: What Broke, What Held
We ran the same bursty checkout API on Cloud Run and Lambda for six months. Cold starts, concurrency, and billing quirks all bit us in ways the marketing pages don't mention.

Sentry Performance Costs Doubled Overnight. Here's What We Found.
Our Sentry bill jumped from ~$900 to ~$2,100 in a single billing cycle with no traffic change. Here's the investigation, the culprits we found, and the sampling strategy we settled on.
