# 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: 2026-10-12
- Author: 友田 陽大
- Tags: Vercel, Next.js, セキュリティ, 脆弱性診断, インフラ
- URL: https://tomodahinata.com/en/blog/vercel-security-configuration-checklist-deployment-protection-guide
- Category: Vercel in production
- Pillar guide: https://tomodahinata.com/en/blog/vercel-production-platform-guide

## Key points

- Vercel is responsible for the platform: infrastructure, networking, and encryption at rest. Its shared responsibility model puts environment variable handling, app authentication, long-term log retention and the choice of settings on you
- New projects default to Standard Protection, which leaves production domains unprotected. Since 2026-09-09, protecting All Deployments, production included, with Vercel Authentication costs nothing extra on every plan
- Since 2026-08-24, environment variables are either Config or Secret. Store keys and tokens as Secrets, which can't be read back after saving, and keep production values out of Preview
- Server Actions are public endpoints reachable by a direct POST. Check authentication and authorization inside each function instead of relying on Proxy (formerly middleware)
- Send per-user responses with Cache-Control: private. Returning s-maxage to a cookie-authenticated request can, under the right conditions, put that response in the CDN cache

---

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](https://vercel.com/docs/security/shared-responsibility) spells out the customer side.

| Area | Vercel | You |
|---|---|---|
| Infrastructure, networking, compute isolation | Responsible | — |
| Encryption in transit and at rest (services Vercel controls) | Responsible | Securing 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 |
| Authentication | Platform authentication; provides Deployment Protection | Your app's authentication |
| Logs | Short-term runtime logs | Setting up Drains for long-term retention and auditing |
| Incident response | Vercel-managed infrastructure | Your 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](/security-audit/vercel) checks the same points.

---

## 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 check | Where | Default | Why it matters |
|---|---|---|---|
| Secrets use the **Secret** type | Project Settings → Environment Variables (each variable's Type) | You choose a type when saving | A Secret is write-only after saving. A Config value stays readable to members with access |
| **Separate Production Secret Values** | Team Settings → Security (an owner enables it) | Optional policy | When on, a Production Secret can't share its value with Preview, Development or custom environments |
| No production values in Preview | Each variable's target environments | Chosen per variable | Preview builds unreviewed branches. A production key there turns Preview into a door to production |
| Branch-scoped Preview variables | Target a specific branch | All non-production branches | A 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 keys | Project 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).

```bash
# 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](/blog/vercel-environment-variables-secrets-oidc-management-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](/blog/nextjs-env-secret-leak-prevention-public-vars-guide).

---

## 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 check | Where | Default | Why it matters |
|---|---|---|---|
| Protection scope | Project → Security → Deployment Protection | **Standard Protection** (everything except production domains) | Standard does not protect your production custom domain |
| Whether you need **All Deployments** | Same | — | Internal tools and unlaunched sites should close the production URL too |
| Protection method | Same | — | Vercel Authentication is the recommended method. Disabling it leaves every existing deployment unprotected |
| Password Protection | Same | — | A paid option on Pro (bought per project since 2026-09-09), included on Enterprise, not available on Hobby |
| Trusted IPs | Same | — | Enterprise only. IPv4 addresses and IPv4 CIDR ranges |
| Team default | Team 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 check | Where | Default | Why it matters |
|---|---|---|---|
| **Git Fork Protection** | Project Settings → Security | On (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 check | No setting | Always applies | For 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 Commits** | Project Settings → Git | Off | Deploys only commits GitHub has verified and automatically cancels the rest (GitHub only) |
| **Build Logs and Source Protection** | Project Settings → Security | On | Limits `/_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](https://nextjs.org/docs/app/guides/data-security), 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.

```ts
// 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.

```ts
// 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.**

| Header | Default | What to do |
|---|---|---|
| `Strict-Transport-Security` | Yes (`max-age=63072000` on custom domains; `*.vercel.app` adds `includeSubDomains; preload`) | Override the value if you need to |
| `Content-Security-Policy` | No | Watch in Report-Only, then enforce |
| `X-Frame-Options` / `X-Content-Type-Options` / `Referrer-Policy` | No default documented | Set 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`.

```ts
// 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](https://nextjs.org/docs/app/guides/content-security-policy) 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.**

| Feature | Plans (as of October 2026) | What to check |
|---|---|---|
| DDoS mitigation (L3, L4, L7) | All plans, automatic | Nothing to configure |
| Custom rules | All plans (up to 3 on Hobby / 40 on Pro / 1000 on Enterprise) | Is access to admin pages and admin APIs narrowed? |
| Rate limiting | All plans (1 rule on Hobby) | Is it on login and contact forms? Counters are per region |
| Attack Mode | All plans, free | Who turns it on during an attack? Your own Functions and Cron Jobs still get through |
| Bot Protection Managed Ruleset | All plans | Inactive by default |
| OWASP core ruleset | Enterprise only | Design on the assumption Pro doesn't have it |
| BotID (Basic) | All plans, free | Do 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](/blog/vercel-firewall-waf-botid-ddos-security-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.

```ts
// 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.**

| Item | Plans (as of October 2026) |
|---|---|
| Runtime log retention | Hobby 1 hour / Pro 1 day / Enterprise 3 days (30 days on Pro or Enterprise with Observability Plus) |
| Drains | Pro and Enterprise |
| Audit Log Drains | Enterprise only |
| Audit Logs (CSV export up to 90 days) | Enterprise only |
| Developer role | Pro and Enterprise |
| Access Groups, project-level roles | Enterprise only |
| SAML SSO | Enterprise; paid add-on on Pro |
| Directory Sync | Enterprise 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.log`s 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

| Item | Default | Recommended | Plan |
|---|---|---|---|
| Type for secret env vars | Chosen per variable | Secret | All plans |
| Separate Production Secret Values | Optional | On | Team Security settings (owner) |
| Preview vs. Production values | Chosen per variable | No production keys in Preview | All plans |
| `NEXT_PUBLIC_` | — | Only values safe to publish | — |
| External cloud credentials | — | OIDC Federation | All plans |
| Deployment Protection scope | Standard (production domains excluded) | All Deployments if production must be hidden | All plans |
| Protection method | — | Vercel Authentication | All plans |
| Password Protection | — | Only when needed | Paid option on Pro, included on Enterprise, not on Hobby |
| Trusted IPs | — | When access must come from your network | Enterprise only |
| Protection Bypass secrets | — | One per purpose; revoke when unused | All plans |
| OPTIONS Allowlist | Empty | Minimal paths (prefix match) | All plans |
| Git Fork Protection | On | Leave on | All plans |
| Require Verified Commits | Off | Turn on if you need it | GitHub only |
| Build Logs and Source Protection | On | Leave on (watch for `public` overrides) | All plans |
| Server Action authorization | — | Authenticate and authorize in each function | — |
| Per-user responses | `public, max-age=0, must-revalidate` | `private, max-age=0` | — |
| Security headers | HSTS only | CSP (start with Report-Only) and others | — |
| WAF custom rules, rate limiting | None | On login and expensive APIs | All plans (rule counts differ) |
| Attack Mode | Off | Decide the procedure for turning it on | All plans |
| Cron authentication | None | `CRON_SECRET`, fail closed | All plans |
| Runtime log retention | 1 hour to 3 days | Drains if you need more | Drains on Pro and Enterprise |
| Audit logs | — | Consider if needed | Enterprise 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](/security-audit/vercel) applies this same checklist to your actual project.
