How to Deploy Vercel Securely (2026)

In April 2026, a wave of Next.js apps deployed on Vercel were found leaking API keys through a single naming mistake: NEXT_PUBLIC_OPENAI_KEY. Every visitor's browser downloaded the key as plain text. Most of those developers had done everything else right. They used Vercel's environment variables panel, added .env to .gitignore, kept secrets out of git. The NEXT_PUBLIC_ prefix is what got them.

This guide covers that issue and six others that security scanners commonly flag on fresh Vercel deployments.

TL;DR

The biggest Vercel security risk is NEXT_PUBLIC_-prefixed secrets baked into your client bundle. Beyond that: preview deployments are public by default, security headers aren't set automatically, and API routes under /api/ are open HTTP endpoints. Fix each in about 30 minutes before your first real users arrive.

Step 1: Audit NEXT_PUBLIC_ Environment Variables

This is the check that matters most. Run it before every production deploy.

1

Find every NEXT_PUBLIC_ variable in your codebase.

# Find variables the app reads from process.env with the public prefix
grep -r "NEXT_PUBLIC_" src/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx"
grep -r "NEXT_PUBLIC_" app/ --include="*.ts" --include="*.tsx" 2>/dev/null

List each one. For every hit, ask: is this value a secret, or is it genuinely public data?

2

Classify each variable.

VariablePublic?Action
NEXT_PUBLIC_SITE_URLYesFine as-is
NEXT_PUBLIC_GA_MEASUREMENT_IDYesFine: analytics IDs are public
NEXT_PUBLIC_OPENAI_API_KEYNoRemove prefix, add server-side route
NEXT_PUBLIC_SUPABASE_SERVICE_KEYNoRemove prefix immediately
NEXT_PUBLIC_STRIPE_SECRET_KEYNoRemove prefix, use Route Handler

The NEXT_PUBLIC_SUPABASE_ANON_KEY is a genuine exception: Supabase's anon key is designed to be public and enforced by Row Level Security. The service_role key must never have NEXT_PUBLIC_.

3

Move secret variables to Route Handlers.

If your client code calls OpenAI directly, move that call server-side:

// app/api/chat/route.ts
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY, // No NEXT_PUBLIC_ prefix
});

export async function POST(request: Request) {
  const { message } = await request.json();
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: message }],
  });
  return Response.json({ reply: response.choices[0].message.content });
}

Your client code calls /api/chat. The key stays on the server.

Verify the fix worked. After deploying, open your app in a browser, press F12, go to Sources, and search for your API key value. If it appears in any .js file, the variable is still in your bundle. Check whether you missed a reference or whether a build cache is serving the old bundle.

Step 2: Configure Security Headers

Vercel sets no security headers by default. Add them via vercel.json in your project root:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=31536000; includeSubDomains"
        },
        {
          "key": "Permissions-Policy",
          "value": "camera=(), microphone=(), geolocation=()"
        },
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.openai.com"
        }
      ]
    }
  ]
}

Adjust the CSP connect-src to match the third-party APIs your app actually calls. Deploy, then run your app through securityheaders.com or CheckYourVibe to verify.

For Next.js, you can also set headers in next.config.js:

// next.config.js
const nextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-Content-Type-Options", value: "nosniff" },
        ],
      },
    ];
  },
};

The vercel.json approach applies before Next.js processes the request. Use it for headers that should apply to all routes including static assets.

Step 3: Enable Deployment Protection on Preview Deploys

Every pull request on Vercel generates a preview URL like my-app-git-pr-42-yourteam.vercel.app. By default, anyone with that URL can access your app, including any staging or test data.

To lock it down:

  1. Go to your Vercel project dashboard.
  2. Click Settings > Deployment Protection.
  3. Enable Vercel Authentication. Visitors must log in with a Vercel account to view any preview.
  4. For extra protection on production, enable Password Protection (Pro plan feature).

If your team uses preview deploys for stakeholder review and your stakeholders don't have Vercel accounts, use the "Share" link option in Vercel's dashboard instead of disabling protection entirely. Shared links have a time limit and don't expose the underlying URL.

Step 4: Scope Environment Variables by Target

Vercel lets you assign each variable to one or more targets: Production, Preview, and Development. Most teams skip this and use the same key everywhere.

The right pattern:

VariableProductionPreviewDevelopment
OPENAI_API_KEYLive keyTest keyTest key
STRIPE_SECRET_KEYLive secretTest secret (sk_test_...)Test secret
DATABASE_URLProd DBStaging DBLocal DB
NEXT_PUBLIC_GA_IDProd GAStaging GA (or empty)Empty

To set this in Vercel: when you add or edit a variable, click the checkbox for each environment. Uncheck Production for test credentials and uncheck Preview/Development for live credentials.

This limits blast radius. If a preview deploy is misconfigured and exposes a variable, it exposes a test key that can't charge real money or write to your production database.

Step 5: Protect Public API Routes

Every file under app/api/ or pages/api/ is a public HTTP endpoint that anyone on the internet can call. A fresh Next.js app has several of these, and it's easy to forget they exist.

Minimum checks before every API route goes live:

// app/api/data/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; // your auth helper

export async function GET(request: NextRequest) {
  // 1. Check authentication before doing anything else
  const user = await getCurrentUser(request);
  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 2. Only return data this user owns
  const data = await db.query(
    "SELECT * FROM records WHERE user_id = $1",
    [user.id]  // parameterized query, not string interpolation
  );

  return NextResponse.json(data);
}

For unauthenticated public routes (contact forms, newsletter signups), add rate limiting. Without it, a simple loop can spam your database or trigger automated email sends.

Never build an API route that accepts a userId parameter from the request body and uses it without verifying the authenticated user owns that ID. This is an Insecure Direct Object Reference (IDOR), the most common API vulnerability in vibe-coded apps.

Step 6: Custom Domain and HTTPS Setup

When you connect a custom domain, Vercel provisions a TLS certificate automatically. A few things to check:

1

Redirect www to apex (or vice versa). In Vercel's Domains settings, add both yourdomain.com and www.yourdomain.com. Set one as the primary and Vercel will redirect the other automatically. Without this, www.yourdomain.com may show a certificate warning if you only registered the apex domain.

2

Set HSTS with a long max-age. Add this to your vercel.json headers once you're confident HTTPS works everywhere:

{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains; preload" }

The preload directive submits your domain to browsers' built-in HSTS preload list. Once preloaded, browsers refuse plain HTTP connections even before the first visit. This prevents SSL stripping attacks on public Wi-Fi.

3

Remove unused custom domains. Every domain you add to Vercel can potentially serve your app. If you moved from an old domain, remove it from the project settings so it can't be claimed by someone else pointing DNS to Vercel.

Step 7: Scan Before Going Public

Before switching your DNS to point at the Vercel deployment, scan your preview URL. A scanner reads your app the way an attacker would: fetching the public pages, inspecting the JavaScript bundle, checking response headers.

CheckYourVibe specifically flags:

  • NEXT_PUBLIC_-prefixed variables visible in the bundle
  • Missing Content-Security-Policy, X-Frame-Options, and HSTS headers
  • Exposed API endpoints that return data without authentication
  • Common Next.js misconfigurations (open redirects in callback URLs, debug endpoints left active)

Run the scan on the preview URL first. That way, any findings are in a non-production environment, and you can fix them before real users arrive.

What does NEXT_PUBLIC_ actually do in Next.js on Vercel?

NEXT_PUBLIC_ tells Next.js to inline that variable's value into the client-side JavaScript bundle at build time. Vercel injects it during the CI/CD build, but it ends up as a plain-text string in the .js files served to every visitor. Any variable prefixed with NEXT_PUBLIC_ is public by design. Never use it for API keys, service account credentials, or any secret that isn't meant to be read by anyone.

Are Vercel preview deployments public by default?

Yes. Every preview deployment URL is publicly accessible without authentication. Anyone with the URL can load the app, which includes any NEXT_PUBLIC_-prefixed variables. Enable Deployment Protection under Project Settings to require Vercel login before a preview can be viewed.

Which security headers does Vercel add automatically?

None of the security-relevant ones. Vercel adds X-Vercel-Id for request tracking but does not set Content-Security-Policy, X-Frame-Options, HSTS, or X-Content-Type-Options by default. Configure all headers in vercel.json or next.config.js.

Should I use separate API keys for production and preview environments?

Yes. Use test credentials for Preview and Development targets, live credentials only in Production. This limits blast radius if a preview deploy is misconfigured and prevents accidental charges or writes to your production service during testing.

How do I add rate limiting to Vercel API routes?

Vercel has no built-in rate limiting. Use Upstash Redis with the @upstash/ratelimit package for serverless-compatible durable limiting, or use Cloudflare in front of Vercel and apply rate limit rules at the edge. For simple per-IP limiting on low-traffic routes, an in-memory sliding window in Edge Middleware works for small deployments.

Check your Vercel deployment for NEXT_PUBLIC_ secrets in the bundle, missing security headers, and exposed API routes. Free scan, no signup required.

How-To Guides

How to Deploy Vercel Securely (2026)