You have deployed to Vercel. It is live. But is it secure?
Vercel makes deployment effortless. Push to main, get a production URL in under a minute. That speed is genuinely impressive, but it also means security decisions that used to happen during a manual deployment process now happen (or don't happen) automatically. The defaults are reasonable, not bulletproof.
We scan thousands of Vercel-hosted Next.js apps at CheckVibe. The same security gaps appear repeatedly. Not because developers are careless, but because Vercel's deployment experience is so smooth that the security configuration step gets skipped entirely.
Here is the 10-point checklist we recommend for every Next.js app running on Vercel.
This is the single most common security mistake in Next.js applications, and Vercel's environment variable UI makes it easy to overlook. Any variable prefixed with NEXT_PUBLIC_ is bundled into client-side JavaScript and shipped to every visitor's browser.
Dangerous:
NEXT_PUBLIC_DATABASE_URL=postgresql://user:password@host/db
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_abc123...
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
Every one of those values is now visible in the browser's developer tools. Anyone can extract them from your JavaScript bundle.
Correct approach:
# Client-safe (public keys only)
NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# Server-only (no NEXT_PUBLIC_ prefix)
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
STRIPE_SECRET_KEY=sk_live_abc123...
DATABASE_URL=postgresql://user:password@host/db
Store server-only variables in the Vercel dashboard under Project Settings > Environment Variables. Do not commit them to .env files in your repository. Set the scope (Production, Preview, Development) appropriately so staging environments do not use production credentials.
Rule: If a key grants write access, admin privileges, or bypasses security policies, it must never carry the NEXT_PUBLIC_ prefix. For more on this pattern, see our guide on Next.js security best practices.
Vercel does not add security headers by default. Without explicit configuration, your Next.js app ships with no Content-Security-Policy, no HSTS preload, no clickjacking protection, and no referrer controls. You need to set these yourself.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://*.supabase.co https://api.stripe.com",
"frame-ancestors 'none'",
].join('; '),
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains; preload',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
];
},
};
export default nextConfig;
Adjust the connect-src directive to include your actual API domains. If you use analytics or third-party scripts, add those origins to the appropriate directives. The goal is to be as restrictive as possible while keeping your app functional.
For a deep dive on each header, see our complete security headers guide.
Every pull request on Vercel generates a unique preview deployment URL. By default, these are publicly accessible. If your preview environments connect to production databases or contain unreleased features, anyone with the URL can access them.
Enable deployment protection:
Preview URLs follow a predictable pattern (project-name-git-branch-name-team.vercel.app), which means they are discoverable. Do not assume obscurity protects them.
The full walkthrough — every protection method, which plan each one needs, and how to lock down production when your plan does not include Password Protection — is in the private deployment section below.
Next.js Server Actions are convenient. They look like function calls. They are actually POST endpoints. Every Server Action is an HTTP endpoint that can be called directly with fetch or curl, bypassing your UI entirely.
Vulnerable:
// app/actions.ts
'use server';
export async function deleteUser(userId: string) {
await db.query('DELETE FROM users WHERE id = $1', [userId]);
}
Anyone can call this action with a forged request. There is no authentication, no authorization, and no input validation.
Fixed:
// app/actions.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
const deleteUserSchema = z.object({
userId: z.string().uuid(),
});
export async function deleteUser(userId: string) {
const parsed = deleteUserSchema.safeParse({ userId });
if (!parsed.success) {
throw new Error('Invalid input');
}
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Unauthorized');
}
// Check admin role
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();
if (profile?.role !== 'admin') {
throw new Error('Forbidden');
}
await db.query('DELETE FROM users WHERE id = $1', [parsed.data.userId]);
}
Rule: Treat every Server Action exactly like an API route. Validate all inputs with a schema library. Check authentication and authorization on every call.
Every file in app/api/ is a publicly accessible HTTP endpoint. Next.js does not enforce any authentication by default. This means every route that reads or writes data must verify the caller's identity.
// app/api/projects/route.ts
import { createClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate and sanitize input before processing
// ... your logic here
return Response.json({ success: true });
}
Do not rely on client-side routing to "hide" API routes. Bots and attackers will discover them through JavaScript bundle analysis, brute-force path scanning, or error messages that leak route structures.
Middleware in Next.js runs at the edge before any page or API route is reached. Use it to enforce authentication on protected routes, redirect unauthenticated users, and apply rate limiting.
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = '/login';
return NextResponse.redirect(redirectUrl);
}
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/api/projects/:path*', '/api/keys/:path*'],
};
The matcher configuration is critical. Without it, middleware runs on every request including static assets, which hurts performance. Be explicit about which paths need protection.
Using Access-Control-Allow-Origin: * in production means any website on the internet can make authenticated requests to your API from a user's browser.
// app/api/data/route.ts
const ALLOWED_ORIGINS = [
'https://yourdomain.com',
'https://app.yourdomain.com',
];
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? '';
if (!ALLOWED_ORIGINS.includes(origin)) {
return new Response('Forbidden', { status: 403 });
}
const data = { /* ... */ };
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
Be explicit about which origins can access your API. If you have multiple subdomains, list them individually rather than using a broad regex that could match unintended domains. For background on CORS misconfigurations and their impact, see our CORS misconfiguration guide.
Vercel provides built-in DDoS protection and, on Pro and Enterprise plans, a Web Application Firewall (WAF). Understanding the boundary of these protections is important.
What Vercel handles automatically:
What Vercel does not handle for you:
Vercel's WAF rules (available on Pro plans) let you block traffic by IP, country, path pattern, and request header. Use them to restrict access to admin routes, block known bad IPs, and limit traffic to specific paths.
Do not assume the WAF replaces application-level security. It is a complementary layer, not a substitute for validating inputs and checking auth in your code.
By default, Next.js generates source maps in production builds. If these are exposed, attackers can read your original source code, understand your application logic, and find vulnerabilities more easily.
Disable source maps in production:
// next.config.ts
const nextConfig: NextConfig = {
productionBrowserSourceMaps: false,
// ... other config
};
In the Vercel dashboard, check Project Settings > Security for options related to:
Also verify that your error handling does not expose stack traces or internal paths to end users. Use generic error messages in production responses.
If your repository is public or accepts contributions from external forks, Vercel will build and deploy preview environments for forked pull requests by default. This means an attacker could submit a PR that modifies your build configuration, injects malicious code, or exfiltrates environment variables during the build step.
Mitigate this:
For open-source projects, this is especially important. A forked PR can run arbitrary code during npm run build, which has access to any environment variables scoped to preview deployments.
"Private" means two different things depending on who is asking. Sometimes you want previews locked down while production stays open to the world. Sometimes you want the whole project, production domain included, behind a login. Vercel handles both through Deployment Protection — and your plan decides how far you can take it.
The methods, per Vercel's documentation:
| Method | What it does | Plan |
|---|---|---|
| Vercel Authentication | Only Vercel users with access to the project can open the deployment | All plans |
| Password Protection | One shared password gates the deployment | Enterprise, or the paid Advanced Deployment Protection add-on on Pro |
| Trusted IPs | Only requests from an allowlist of IPv4 addresses get through | Enterprise |
| Passport | Visitors authenticate through your own identity provider | Enterprise |
| Scope | Covers | Plan |
|---|---|---|
| Standard Protection | Every deployment except your production domains | All plans |
| All Deployments | Everything, production domains included | Pro and Enterprise |
On the Hobby (free) plan, the practical combination is Vercel Authentication with Standard Protection: preview URLs and generated deployment URLs require a Vercel login, and your production domain stays public. Making a production domain private with Vercel's own tooling means Pro or Enterprise (All Deployments), or Enterprise Trusted IPs if you want production restricted while previews stay open.
Two things to know before you flip it on. Deployment Protection authenticates every request, including requests that would have reached your middleware — so your own auth code never runs for a blocked visitor. And once Standard Protection is enabled, the generated production URL is no longer public, which breaks any fetch built from VERCEL_URL or VERCEL_BRANCH_URL.
// Before — breaks once the generated URL is protected
await fetch(`https://${process.env.VERCEL_URL}/api/stats`);
// After — same origin, so the visitor's auth cookie travels with it
await fetch('/api/stats');
For server-side calls, build the URL from the incoming request's origin and forward the request cookies rather than reaching for VERCEL_URL.
Vercel's docs are unambiguous: a generated deployment URL "is publicly accessible by default," and you configure it to be private using Deployment Protection. Until you do, there is no password, no login, and no guarantee it stays out of search results — only a URL that is hard to guess.
project-name-git-branch-name-team.vercel.app # branch URL, follows the latest commit
project-name-a1b2c3d4e-team.vercel.app # commit URL, frozen at that build
Hard to guess is not the same as secret. Nobody has to guess these, because they get pasted into pull request comments, Slack threads, Linear tickets, and screenshots. The branch URL format is also partly derivable: on a public repo, the project name, branch name, and team slug are all public information.
So the honest description of an unprotected preview is: reachable by anyone who ever sees the link, for as long as your deployment retention policy keeps it alive. Whatever that preview points at — a staging database with a copy of production, a live Stripe test key, an unreleased feature — is reachable too.
If Password Protection is not in your plan and you still need the whole site gated, do it in middleware.ts. It runs at the edge before any page or route handler, so nothing renders for a visitor who cannot authenticate.
// middleware.ts — HTTP Basic auth across the whole app
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
function safeEqual(a: string, b: string) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function middleware(request: NextRequest) {
const user = process.env.SITE_USER;
const password = process.env.SITE_PASSWORD;
// Not configured? Do not lock yourself out of your own deployment.
if (!user || !password) return NextResponse.next();
const header = request.headers.get('authorization') ?? '';
const expected = `Basic ${btoa(`${user}:${password}`)}`;
if (!safeEqual(header, expected)) {
return new NextResponse('Authentication required', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Restricted", charset="UTF-8"',
'X-Robots-Tag': 'noindex, nofollow',
},
});
}
return NextResponse.next();
}
export const config = {
// Everything except Next.js internals and the favicon
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Store SITE_USER and SITE_PASSWORD as server-only environment variables — no NEXT_PUBLIC_ prefix, per checklist item #1 — and scope them to the environments you actually want locked. Basic auth sends credentials on every request, so it is only acceptable over HTTPS, which on Vercel it always is.
If you already have real sessions, skip the shared password and gate on the session instead. Reuse the Supabase middleware from checklist item #6, but widen the check: redirect anonymous visitors to /login for every path rather than only /dashboard/:path*. You get a private site with per-user accounts and an audit trail instead of one password everybody shares.
One limitation worth naming: middleware protects your application, not your deployment. The request still reaches Vercel's edge and runs your code — it just gets a 401 back. Deployment Protection blocks earlier, before middleware executes. The DIY route is what you use when your plan does not include the platform feature, not a strict upgrade over it.
Locking a deployment and de-indexing it are separate jobs. Vercel Authentication returns 401 to crawlers, which handles it in practice. But if you gate at the app layer, or run a staging domain that is reachable without auth, send an explicit X-Robots-Tag on every non-production deployment:
// next.config.ts
const isProduction = process.env.VERCEL_ENV === 'production';
const nextConfig: NextConfig = {
async headers() {
if (isProduction) return [];
return [
{
source: '/(.*)',
headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }],
},
];
},
};
Vercel sets VERCEL_ENV automatically to production, preview, or development, so this needs no configuration of its own.
Do not try to solve this with robots.txt alone. Disallowing a path stops crawlers from fetching it, which also stops them from ever reading your noindex — the URL can still appear in results when something links to it. The header is the reliable control. Authentication is the certain one.
The moment protection goes on, your E2E tests, uptime checks, and Lighthouse runs start collecting 401s. Vercel's Protection Bypass for Automation issues a secret you send as the x-vercel-protection-bypass header (or query parameter), so scripted traffic passes while browsers still hit the login screen.
Generate it under Settings > Deployment Protection, store it as a secret in your CI provider, and rotate it if it ever lands in a log. For a one-off — a client or designer who needs to see a single protected preview — generate a shareable link for that deployment instead of turning protection off for everyone.
Beyond the checklist, these are the mistakes we see most often in production Vercel deployments.
Exposing secrets via NEXT_PUBLIC_ variables. This shows up in roughly 1 in 5 scans we run on Next.js apps. Developers copy environment variable configurations between projects and forget to remove the prefix from sensitive keys.
No Content-Security-Policy header. Without CSP, your app has no browser-enforced protection against XSS. If an attacker finds a way to inject a script tag, nothing stops it from executing.
Preview deployments accessible to the public. Preview URLs are not secret. They follow predictable naming patterns and are often indexed by search engines or shared in public Slack channels and GitHub comments.
Source maps shipped in production. These give attackers a complete map of your application code. Combined with other information, they make vulnerability discovery significantly faster.
Wildcard CORS in API routes. Using Access-Control-Allow-Origin: * on routes that return user data or accept mutations lets any website make requests on behalf of your users.
Missing auth on Server Actions. Because Server Actions look like regular function calls in your component code, it is easy to forget that they are exposed HTTP endpoints that anyone can invoke.
Vercel offers several security features beyond basic hosting. Not all are available on every plan, but they are worth knowing about.
Web Application Firewall (WAF): Available on Pro and Enterprise plans. Configure rules to block traffic by IP, geography, path, or header. Useful for restricting admin panels and blocking abusive traffic patterns.
DDoS Protection: Included on all plans. Vercel's edge network absorbs volumetric attacks automatically. For application-layer protection, you still need rate limiting in your code.
Deployment Protection: Password protection and Vercel Authentication for preview and staging environments. Essential for any project where preview deployments touch real data.
OIDC Federation: For Enterprise plans, Vercel supports OpenID Connect federation with identity providers. This lets you control who can deploy and access project settings through your existing SSO infrastructure.
Attack Challenge Mode: When under active attack, Vercel can automatically present challenge pages to suspicious traffic. This is managed through the dashboard or API.
Secure Compute: Enterprise feature that runs serverless functions in dedicated, isolated environments. Relevant for apps handling regulated data (healthcare, finance).
Note that Vercel Speed Insights and Web Analytics are performance tools, not security features. They measure Core Web Vitals and visitor metrics, but they do not detect or prevent attacks.
CheckVibe runs 36 automated security checks against any publicly accessible website, including Vercel-hosted Next.js apps. Our scanner suite includes a dedicated Vercel scanner that checks for platform-specific issues.
Here is what we detect on Vercel deployments:
Security headers analysis. We check for the presence and correct configuration of CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Missing or misconfigured headers are flagged with specific remediation steps.
Exposed secrets detection. Our API key leak scanner examines your client-side JavaScript bundle for patterns matching common API keys, database connection strings, and tokens. If a NEXT_PUBLIC_ variable contains something that looks like a secret key, we flag it.
CORS misconfiguration. We test your API endpoints for overly permissive CORS headers. Wildcard origins, missing credentials handling, and other CORS issues are identified and reported.
Technology fingerprinting. We identify that your app runs on Vercel and Next.js, then apply platform-specific security checks. The scanner knows which default configurations are insecure for your particular stack.
SSL and certificate analysis. We verify your TLS configuration, certificate chain, and HSTS setup. While Vercel handles certificates automatically, misconfigurations can still occur with custom domains.
Site crawling for comprehensive coverage. Our crawler discovers pages and API endpoints across your site, ensuring that security checks run against your entire attack surface, not just the homepage.
You can run a free scan on your Vercel deployment right now and see exactly which items from this checklist your app passes or fails.
Partially. Vercel handles infrastructure security: TLS certificates, DDoS protection, network isolation, and edge caching. It does not handle application security: authentication, authorization, input validation, security headers, CORS, or secret management. Those are your responsibility as the developer. Think of Vercel as a secure building — it has locks on the front door, but you still need to lock your apartment.
Not by default. Preview deployments are publicly accessible via predictable URLs. Enable Deployment Protection in your Vercel project settings to require authentication for preview URLs. Never scope production secrets (database credentials, API keys) to preview environments.
For most applications, Vercel's built-in DDoS protection combined with proper application-level security is sufficient. If you are handling sensitive data, processing payments, or facing targeted attacks, the Vercel WAF (Pro plan) adds useful IP-based and path-based blocking rules. It is not a replacement for input validation and auth checks in your code.
Configure them in next.config.ts using the headers() function as shown in checklist item #2. You can also add headers via vercel.json, but the next.config.ts approach is more maintainable and keeps your security configuration in version control alongside your application code.
If source maps are enabled in production (which is the default), attackers can reconstruct your original source code from the browser's developer tools. Set productionBrowserSourceMaps: false in next.config.ts. Additionally, enable Source Protection in Vercel's dashboard settings to prevent direct access to your deployment's source files.
Edge middleware is a good location for basic rate limiting because it runs before your application code. However, Vercel's edge runtime has limitations on execution time and available APIs. For sophisticated rate limiting (sliding windows, per-user limits, tiered thresholds), consider a dedicated rate limiting service or implement it in your API routes with a backing store like Redis.
Yes, until you change it. Vercel's docs state that generated deployment URLs are publicly accessible by default. They are unlisted rather than private, and nobody needs to guess them because they get pasted into pull request comments, Slack threads, and screenshots. Anyone holding the link reaches the deployment and whatever it connects to. Enable Deployment Protection in Project Settings before a preview touches real data.
There are two routes. Vercel's built-in Password Protection lives under Project Settings > Deployment Protection, but it requires Enterprise or the paid Advanced Deployment Protection add-on on Pro. On any plan you can do it yourself in middleware.ts: compare the incoming Authorization header against a Basic credential held in server-only environment variables, and return 401 with a WWW-Authenticate header when it does not match.
Vercel gives you TLS, isolated builds and runtimes, and network-layer DDoS protection without any setup. It does not give you security headers, authentication on your routes, input validation, CORS rules, or private deployments — those are all opt-in and default to off. The platform is secure; your application is only as secure as the configuration you layer on top of it. Treat the defaults as a floor, not a finish line.
Vercel is an excellent platform for deploying Next.js apps. But deploying is step one. Securing what you deployed is step two, and it does not happen automatically.
Work through this checklist from top to bottom. The first three items — environment variables, security headers, and deployment protection — will address the most common vulnerabilities we see. Then tackle auth, CORS, and the remaining items.
If you want to know exactly where your Vercel deployment stands right now, run a free CheckVibe scan. In under 60 seconds, you will get a full security report covering headers, exposed secrets, CORS, SSL, and 30+ additional checks tailored to your stack.
Paste your URL and get a security report in 30 seconds — 100+ automated checks with AI-ready fix prompts.
Scan your site freeRelated articles
The essential security checklist for SaaS founders shipping their first product. Covers auth, data protection, API security, payments, and monitoring — no security team needed.
The complete Supabase security checklist. Covers RLS, API keys, auth hardening, storage policies, edge functions, and more — with code examples and automated scanning.
Next.js apps are fast to build but easy to misconfigure. Here are 10 specific security issues most developers miss, with code examples for each vulnerability and its fix.