Skip to main content
Vercel in production
Vercel
Next.js
セキュリティ
脆弱性診断
インフラ

Vercel Security Configuration Checklist: Environment Variables, Deployment Protection, Git Integration and the Next.js Boundary

A configuration audit checklist for teams running Next.js on Vercel: Config/Secret env vars, Deployment Protection defaults, Git Fork Protection, Server Action auth, caching and Cron, checked against the docs as of October 2026.

Published
Reading time
18 min read
Author
友田 陽大
Share

Put Next.js on Vercel and deployments, TLS and DDoS mitigation all work before you touch a setting. That's convenient, and it's also why the decisions that belong to you tend to stay at their defaults: production keys sitting in Preview, a production domain anyone can open, per-user responses landing in the CDN. You can find every one of these by reading the dashboard next to the repository.

This is a configuration audit checklist for tech leads at startups running Next.js on Vercel. For each item it lists what to check, where the setting lives, its default, and why it matters. Feature names, defaults and plan availability were checked against the Vercel and Next.js docs as of October 2026. Vercel renamed and repriced several of these features in August and September 2026, so older articles may disagree with what you read here.

Environment variables and the Firewall each have their own deep-dive post. This one covers the essentials and links to them.


Start with the split: what Vercel secures and what you decide

Short answer: Vercel is responsible for the platform (infrastructure, networking, encryption at rest). Handling environment variables, authenticating your app's users, retaining logs long term and choosing which features to use are yours.

Vercel's Shared Responsibility Model spells out the customer side.

AreaVercelYou
Infrastructure, networking, compute isolationResponsible—
Encryption in transit and at rest (services Vercel controls)ResponsibleSecuring the third-party integrations you connect
Code and environment variables—Managing code, including the exposure of environment variables, and handling secrets such as API keys properly
AuthenticationPlatform authentication; provides Deployment ProtectionYour app's authentication
LogsShort-term runtime logsSetting up Drains for long-term retention and auditing
Incident responseVercel-managed infrastructureYour code, configuration and integrations

So "we're on Vercel" doesn't make you secure. How you configured what Vercel provides decides your side of the line. The checklist below is an inventory of that configuration. If your team can't find the time to go through it, my Vercel configuration and Next.js deployment boundary review checks the same points.


When did you last inventory your Vercel settings?

A review of your Vercel configuration and the Next.js deployment boundary

1. Environment variables and environment separation

Short answer: Store keys, tokens and passwords as Secrets, and use different values in Preview and Production. Anything prefixed NEXT_PUBLIC_ ships to the browser, so never put a secret there.

What to checkWhereDefaultWhy it matters
Secrets use the Secret typeProject Settings → Environment Variables (each variable's Type)You choose a type when savingA Secret is write-only after saving. A Config value stays readable to members with access
Separate Production Secret ValuesTeam Settings → Security (an owner enables it)Optional policyWhen on, a Production Secret can't share its value with Preview, Development or custom environments
No production values in PreviewEach variable's target environmentsChosen per variablePreview builds unreviewed branches. A production key there turns Preview into a door to production
Branch-scoped Preview variablesTarget a specific branchAll non-production branchesA branch-specific value overrides the Preview variable with the same name
What's behind NEXT_PUBLIC_Code and the variable list—Inlined into the JS bundle at build time and delivered to the browser
OIDC instead of long-lived keysProject Settings → Security / your cloud provider—Connect to AWS, GCP or Azure with short-lived tokens, so long-lived credentials never sit in environment variables

One change to know about: on 2026-08-24, the "Sensitive" toggle was replaced by two types, Config and Secret. Existing Sensitive variables are automatically treated as Secrets. The "Enforce Sensitive Environment Variables" team policy is deprecated and replaced by the Separate Production Secret Values policy above. Sensitive variables used to be limited to Production and Preview, but Secrets are allowed in Development.

From the CLI, pass --type secret to vercel env add (--visibility is a deprecated alias).

# Add a production Secret (you'll be prompted for the value)
vercel env add STRIPE_SECRET_KEY production --type secret

Build logs get one safeguard: a Secret value of 32 characters or longer that appears in a build log is replaced with [REDACTED]. That covers build logs only. The docs say nothing about redaction in runtime logs (section 9).

Custom environments such as staging are available on Pro and Enterprise. The NEXT_PUBLIC_ trap and a type-safe env boundary are covered in the Vercel environment variables and secrets guide. How secrets leak into client code, and how to detect it, is in how Next.js environment variables leak secrets and how to prevent it.


2. Deployment Protection: production domains are not covered by default

Short answer: New projects default to Standard Protection, which protects Preview but not production domains. To hide production too, choose All Deployments. Since 2026-09-09, All Deployments with Vercel Authentication costs nothing extra on every plan.

What to checkWhereDefaultWhy it matters
Protection scopeProject → Security → Deployment ProtectionStandard Protection (everything except production domains)Standard does not protect your production custom domain
Whether you need All DeploymentsSame—Internal tools and unlaunched sites should close the production URL too
Protection methodSame—Vercel Authentication is the recommended method. Disabling it leaves every existing deployment unprotected
Password ProtectionSame—A paid option on Pro (bought per project since 2026-09-09), included on Enterprise, not available on Hobby
Trusted IPsSame—Enterprise only. IPv4 addresses and IPv4 CIDR ranges
Team defaultTeam Settings—Sets the protection level and method new projects start with

Read the scope of Standard Protection carefully. The docs say it "protects all domains except production domains." The per-deployment *.vercel.app URLs are protected, but a production domain like example.com is open to anyone.

With Vercel Authentication on, a protected deployment is visible to team members with access to the project, Vercel users who have been granted access, anyone holding a Shareable Link, and tools that send the bypass header described below.

Audit the holes you opened

Once protection is on, check the exceptions you carved out.

  • Protection Bypass for Automation (all plans): a secret sent in the x-vercel-protection-bypass header (or query parameter) gets through protection. Each secret works on every deployment in the project and stays valid until you revoke it. You can create several per project, so issue one per purpose (CI, E2E tests) and delete the ones you no longer need. Webhook senders that can't set headers can receive it as a query parameter, but then the secret lives in that third party's settings page.
  • Shareable Links (all plans): grant external access to a specific branch deployment through a URL with a query parameter. Track who has one and revoke them.
  • OPTIONS Allowlist (all plans): for CORS preflight, removes protection from any OPTIONS request whose path starts with a listed path. Listing /api also opens /api/v1/users. It's a prefix match, so keep the list as narrow as you can.

Automation tools can also reach protected deployments through Trusted Sources, which sends a short-lived OIDC token in the x-vercel-trusted-oidc-idp-token header.


3. Git integration: whose commits get deployed

Short answer: Don't let pull requests from forks deploy without approval, and keep build logs and source closed as they are by default.

What to checkWhereDefaultWhy it matters
Git Fork ProtectionProject Settings → SecurityOn (can be disabled)A pull request from a fork needs approval from you or a team member before it deploys. Protects environment variables and the OIDC token from leaking
Commit author checkNo settingAlways appliesFor private repositories, a commit deploys only if its author also has access to the Vercel project (applies to GitHub organizations and other org-owned repositories)
Require Verified CommitsProject Settings → GitOffDeploys only commits GitHub has verified and automatically cancels the rest (GitHub only)
Build Logs and Source ProtectionProject Settings → SecurityOnLimits /_src (source and build output) and /_logs (build logs) to team members

Turn off Git Fork Protection and an outsider's pull request gets built with your Preview environment variables. The docs warn you not to disable it until you've reviewed your environment variables.

Build Logs and Source Protection is overridden by the CLI's --public option or the public property in vercel.json. Check that no "public": true is left in the repository. Toggling the setting also doesn't change existing deployments.


4. The Next.js boundary: Server Actions are public endpoints

Short answer: Treat Server Actions and Route Handlers as public APIs that can be called without your UI. Check authentication and authorization inside each function, not in Proxy (formerly middleware).

According to the Next.js Data Security guide, an exported Server Action is reachable by a direct POST request, not only through your app's UI. It can be called externally even if nothing in your code imports it. Next.js adds protections such as encrypted action IDs, but the guide still says to verify authentication and authorization inside each action.

// app/invoices/actions.ts
"use server";

import { z } from "zod";
import { verifySession } from "@/lib/dal"; // server-only data access layer
import { db } from "@/lib/db";

const DeleteInvoiceInput = z.object({ invoiceId: z.uuid() });

export async function deleteInvoice(formData: FormData): Promise<void> {
  // 1. Authentication: check here even if Proxy already ran
  const session = await verifySession();
  if (!session) throw new Error("Unauthorized");

  // 2. Input validation: it can be POSTed directly, so don't trust the argument types
  const { invoiceId } = DeleteInvoiceInput.parse({
    invoiceId: formData.get("invoiceId"),
  });

  // 3. Authorization: make ownership part of the delete condition
  await db.invoice.deleteMany({
    where: { id: invoiceId, ownerId: session.userId },
  });
}

What to check:

  • Is Proxy your only authorization layer? Next.js 16 deprecated middleware and renamed it proxy. The Proxy docs warn that a matcher change or a refactor that moves a Server Function to another route can silently remove Proxy coverage, and tell you to verify inside each Server Function rather than relying on Proxy alone. The authentication guide also recommends keeping Proxy to optimistic checks that only read the cookie.
  • The lesson of CVE-2025-29927. This critical vulnerability, published in March 2025, made it possible to bypass authorization if the check lived in middleware. Fixed versions are 15.2.3, 14.2.25, 13.5.9 and 12.3.5. The advisory states that "Next.js deployments hosted on Vercel are automatically protected," so apps on Vercel were not exposed. The lesson is about design: concentrate authorization in one place, middleware, and everything falls when that one place is bypassed.
  • Are you relying on closure encryption? When a Server Action defined inside a component captures outer variables, those values make a round trip through the client. Next.js encrypts them automatically, but the docs say they don't recommend relying on encryption alone. Keep secrets out of closures.
  • Is serverActions.allowedOrigins wider than it needs to be? Server Actions can only be invoked with POST, and Next.js compares the Origin header with Host (or X-Forwarded-Host) to stop CSRF. Add hosts to allowedOrigins only when a reverse proxy setup requires it.
  • Have you narrowed where process.env is read? The Data Security guide recommends a data access layer marked with import 'server-only' as the only code that reads process.env.

5. Keep per-user responses out of the cache

Short answer: Send anything that differs per user with Cache-Control: private. If a cookie-authenticated request gets a response with s-maxage, the conditions for CDN caching can all be met.

Vercel's CDN does not cache a response when any of these hold:

  • The request has an Authorization header
  • The response has a set-cookie header
  • Cache-Control contains private, no-cache or no-store
  • The response has Vary: *, or a Vary naming a high-cardinality header such as Cookie

The default Cache-Control is public, max-age=0, must-revalidate, which tells both the CDN and the browser not to cache. The risk comes when you add s-maxage yourself. Cookie-based sessions don't use the Authorization header, so they don't hit the exclusion above. Put s-maxage on a route that returns user-specific JSON, send no set-cookie, and that response can become cacheable.

// app/api/me/route.ts
import { verifySession } from "@/lib/dal";
import { getProfile } from "@/lib/profile";

export async function GET() {
  const session = await verifySession();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const profile = await getProfile(session.userId);
  // Per-user response: keep it out of the shared CDN cache
  return Response.json(profile, {
    headers: { "Cache-Control": "private, max-age=0" },
  });
}

Since 2026-09-30, Vercel's CDN no longer caches responses whose Vary includes Cookie. That's a change toward safety, but Vercel's guidance for cookie-dependent responses is still to use Cache-Control: private. State private explicitly instead of relying on Vary: Cookie. Responses that weren't cached show x-vercel-cache: MISS, and runtime logs record vary_key_denied:cookie.


6. Security headers: HSTS is the only default

Short answer: HSTS is the only security header the Vercel docs describe as a default. Set CSP and the rest yourself, and start CSP in Report-Only mode.

HeaderDefaultWhat to do
Strict-Transport-SecurityYes (max-age=63072000 on custom domains; *.vercel.app adds includeSubDomains; preload)Override the value if you need to
Content-Security-PolicyNoWatch in Report-Only, then enforce
X-Frame-Options / X-Content-Type-Options / Referrer-PolicyNo default documentedSet them yourself

You can set headers in headers() in next.config.ts, under headers in vercel.json, or in vercel.ts. Use only one of vercel.ts and vercel.json.

// next.config.ts
import type { NextConfig } from "next";

// Start in Report-Only, watch for violations, and switch the key
// to Content-Security-Policy once you're sure nothing breaks
const csp = [
  "default-src 'self'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data: https:",
  "object-src 'none'",
  "base-uri 'self'",
  "frame-ancestors 'none'",
].join("; ");

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Content-Security-Policy-Report-Only", value: csp },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "X-Frame-Options", value: "DENY" },
        ],
      },
    ];
  },
};

export default nextConfig;

Enforcing script-src 'self' from this config as written will block the inline scripts the Next.js App Router puts on the page. That's why this example starts as Report-Only and you check the violation reports. The Next.js CSP guide shows two approaches that work when enforced. A nonce is generated per request, so it requires dynamic rendering. A hash-based CSP through Subresource Integrity (SRI) keeps static generation, but it is experimental and App Router only. If most of your pages are served statically, weigh that cost when you pick an approach.


7. Firewall and rate limiting

Short answer: Custom rules, rate limiting, Attack Mode and BotID Basic are available on every plan. At minimum, put rate limits on login, sign-up and expensive APIs.

FeaturePlans (as of October 2026)What to check
DDoS mitigation (L3, L4, L7)All plans, automaticNothing to configure
Custom rulesAll plans (up to 3 on Hobby / 40 on Pro / 1000 on Enterprise)Is access to admin pages and admin APIs narrowed?
Rate limitingAll plans (1 rule on Hobby)Is it on login and contact forms? Counters are per region
Attack ModeAll plans, freeWho turns it on during an attack? Your own Functions and Cron Jobs still get through
Bot Protection Managed RulesetAll plansInactive by default
OWASP core rulesetEnterprise onlyDesign on the assumption Pro doesn't have it
BotID (Basic)All plans, freeDo you use it on abuse-prone actions such as sign-up and checkout?

What used to be called "Attack Challenge Mode" is Attack Mode in the current docs. Writing rules and wiring up BotID are covered in the Vercel Firewall, WAF and BotID implementation guide. The Firewall at the edge is one layer of defense in depth. It won't catch the authorization mistakes from section 4.


8. Cron and webhooks: verify the caller

Short answer: Authenticate Cron endpoints with CRON_SECRET and reject when it isn't set. Verify webhook signatures from the sender. Make both safe to receive twice.

A Cron endpoint is an ordinary HTTP route, so anyone can call it until you protect it. Set CRON_SECRET on the project and Vercel sends its value as Authorization: Bearer <value> when the cron job runs. Vercel recommends a random string of at least 16 characters.

// app/api/cron/cleanup/route.ts
import { timingSafeEqual } from "node:crypto";
import { purgeExpiredSessions } from "@/lib/jobs";

function isAuthorizedCron(header: string | null, secret: string | undefined): boolean {
  // Always reject when CRON_SECRET is not set (fail closed)
  if (!secret || !header) return false;
  const expected = Buffer.from(`Bearer ${secret}`);
  const actual = Buffer.from(header);
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

export async function GET(request: Request) {
  if (!isAuthorizedCron(request.headers.get("authorization"), process.env.CRON_SECRET)) {
    return new Response("Unauthorized", { status: 401 });
  }

  // Make the job idempotent: receiving the same run twice must not change the result
  await purgeExpiredSessions();
  return Response.json({ ok: true });
}

The official sample also fails closed, starting with !cronSecret ||. If you instead write if (process.env.CRON_SECRET && ...), any environment missing the variable lets anyone run the job.

Cron behavior to keep in mind:

  • Vercel calls your production deployment URL.
  • The same scheduled run can arrive more than once, so make jobs idempotent.
  • Redirects aren't followed. A 3xx response ends the invocation.
  • Hobby is limited to once per day (with ±59 minutes of timing drift). Pro and Enterprise can run as often as once per minute.

For webhook endpoints, when the sender signs its requests, as Stripe does, always verify the signature before doing any work. If you put a Protection Bypass secret into a URL so Preview can receive webhooks, add that secret to your inventory too.


9. Logs and team access

Short answer: Your plan sets runtime log retention, and long-term storage through Drains is yours to set up. Keeping secrets out of logs is the app's job.

ItemPlans (as of October 2026)
Runtime log retentionHobby 1 hour / Pro 1 day / Enterprise 3 days (30 days on Pro or Enterprise with Observability Plus)
DrainsPro and Enterprise
Audit Log DrainsEnterprise only
Audit Logs (CSV export up to 90 days)Enterprise only
Developer rolePro and Enterprise
Access Groups, project-level rolesEnterprise only
SAML SSOEnterprise; paid add-on on Pro
Directory SyncEnterprise only

Build logs get the redaction from section 1, but the docs don't say runtime logs redact secret values. What follows is our own inference: if your app console.logs request headers or tokens, assume those values stay in the runtime logs, and reach your Drain destination too if you've set one up. The shared responsibility model also places the proper handling of sensitive information on the customer.

For team permissions, the Developer role, available on Pro, helps. Developers can manage Preview and Development environment variables but can't change production environment variables or team settings. If everyone is a Member, see whether you can narrow who touches production keys.


One-page summary: Vercel security configuration checklist

ItemDefaultRecommendedPlan
Type for secret env varsChosen per variableSecretAll plans
Separate Production Secret ValuesOptionalOnTeam Security settings (owner)
Preview vs. Production valuesChosen per variableNo production keys in PreviewAll plans
NEXT_PUBLIC_—Only values safe to publish—
External cloud credentials—OIDC FederationAll plans
Deployment Protection scopeStandard (production domains excluded)All Deployments if production must be hiddenAll plans
Protection method—Vercel AuthenticationAll plans
Password Protection—Only when neededPaid option on Pro, included on Enterprise, not on Hobby
Trusted IPs—When access must come from your networkEnterprise only
Protection Bypass secrets—One per purpose; revoke when unusedAll plans
OPTIONS AllowlistEmptyMinimal paths (prefix match)All plans
Git Fork ProtectionOnLeave onAll plans
Require Verified CommitsOffTurn on if you need itGitHub only
Build Logs and Source ProtectionOnLeave on (watch for public overrides)All plans
Server Action authorization—Authenticate and authorize in each function—
Per-user responsespublic, max-age=0, must-revalidateprivate, max-age=0—
Security headersHSTS onlyCSP (start with Report-Only) and others—
WAF custom rules, rate limitingNoneOn login and expensive APIsAll plans (rule counts differ)
Attack ModeOffDecide the procedure for turning it onAll plans
Cron authenticationNoneCRON_SECRET, fail closedAll plans
Runtime log retention1 hour to 3 daysDrains if you need moreDrains on Pro and Enterprise
Audit logs—Consider if neededEnterprise only

Summary

Vercel's defaults favor "it just works." Production domains are public, you pick each variable's type, and HSTS is the only security header. Tightening that to fit your team is your job.

If you need a priority order, start with two checks: are production keys in Preview, and do your Server Actions authorize inside the function. Both can be confirmed with a single pass through the settings and the code. If you'd like a third party to look at the settings and the code together, my Vercel configuration security review applies this same checklist to your actual project.

Frequently asked questions

Are Vercel preview environments hidden from the public by default?
New projects default to Standard Protection, which protects Preview Deployments but not production domains. If you also need to hide production, for an internal tool or a site that hasn't launched, set Deployment Protection to All Deployments. Since 2026-09-09, All Deployments with Vercel Authentication costs nothing extra on every plan.
Can I still use Sensitive environment variables on Vercel?
On 2026-08-24 the Sensitive toggle was replaced by two types, Config and Secret. Existing Sensitive variables are automatically treated as Secrets, with no migration needed. A Secret is write-only: members can't view it after saving, so use it for passwords, API keys and tokens. Config is for values you need to read back later.
Is a single authentication check in Next.js Proxy (formerly middleware) enough?
No. The Next.js docs say to verify authentication and authorization inside each Server Function rather than relying on Proxy alone, because Server Actions can be called with a direct POST that never goes through your UI. Keep Proxy to optimistic checks that only read the cookie, and always check inside the function that touches data.
Can anyone call my Vercel Cron endpoint?
Yes, unless you protect it. Add an environment variable named CRON_SECRET and Vercel sends its value as a Bearer token in the Authorization header when the cron job runs. Reject any request that doesn't match with a 401, and also reject when CRON_SECRET is not set (fail closed). The same run can arrive more than once, so make the job idempotent.
Does Vercel add security headers automatically?
HSTS (Strict-Transport-Security) is the only security header the docs describe as a default. You set CSP, X-Frame-Options, X-Content-Type-Options and Referrer-Policy yourself. Vercel's docs also recommend starting CSP with Content-Security-Policy-Report-Only and watching for violations before you enforce it.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

When did you last inventory your Vercel settings?

A review of your Vercel configuration and the Next.js deployment boundary

Production keys in Preview, preview URL protection, Git integration, the Firewall, cron, caching of per-user responses and authorization in Server Actions — the dashboard settings checked against the repository. The subject is your project’s configuration, not Vercel itself.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading