v1.3.1 AI Drafted AI Validated

Vercel Hardening Guide

DevOps Last updated: 2026-09-25

Comprehensive platform security for authentication, WAF, deployment protection, secrets, network isolation, security headers, and monitoring

View:

Overview

Vercel is a frontend cloud platform providing deployment, hosting, and serverless compute. Its attack surface includes REST API tokens, deployment secrets, Git integrations, serverless functions, edge middleware, DNS management, and third-party marketplace integrations. Compromised access exposes deployment secrets, environment variables, source code, and enables malicious deployments or supply chain attacks.

Shared Responsibility Model

Vercel operates under a shared responsibility model (source):

Vercel manages: Infrastructure security, DDoS mitigation (L3/L4/L7), TLS encryption (automatic HTTPS with TLS 1.2/1.3), platform patching, compute isolation, data encryption at rest (AES-256), certificate management, and edge network operations across 126 PoPs globally.

Customer must configure: Application-level authentication, security headers (CSP, X-Frame-Options, etc.), environment variable scoping and access controls, WAF custom rules, deployment protection settings, RBAC and team access policies, log drain forwarding to SIEM, OIDC federation for CI/CD, and domain/DNS security.

Intended Audience

  • Security engineers managing deployment platforms
  • DevOps and platform engineering teams
  • GRC professionals assessing deployment security posture
  • Third-party risk managers evaluating hosting integrations

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers Vercel platform security configurations including authentication and RBAC, deployment protection, Web Application Firewall, network security and DDoS mitigation, security headers, secrets management, domain security, and monitoring and detection. Application-level security (e.g., Next.js framework hardening) is out of scope but referenced where relevant.


Table of Contents

  1. Authentication & Access Controls
  2. Deployment Security
  3. Web Application Firewall
  4. Network Security
  5. Security Headers
  6. Secrets Management
  7. Domain & Certificate Security
  8. Monitoring & Detection
  9. Framework CVE Management (Next.js)
  10. Customer Misconfiguration Anti-Patterns

Appendices: A. Edition Compatibility · B. References · C. April 2026 Incident Response Playbook


1. Authentication & Access Controls

1.1 Enforce SSO with SAML

Profile Level: L1 (Crawl)

NIST 800-53: IA-2(1), IA-8

Description

Configure SAML Single Sign-On to centralize authentication through your identity provider and eliminate password-based Vercel logins.

Rationale

Why This Matters:

  • Centralizes authentication policy enforcement through your IdP
  • Enables MFA enforcement at the IdP level rather than relying on individual user compliance
  • Provides single point of revocation when employees leave

Attack Prevented: Credential stuffing, password reuse, unauthorized access after employee departure

Prerequisites

  • Vercel Enterprise plan (or Pro with SSO add-on)
  • SAML-compatible IdP (Okta, Entra ID, Google, OneLogin, etc. – 24+ supported)
  • Team Owner access in Vercel

ClickOps Implementation

Step 1: Configure SAML IdP

  1. Navigate to: Team Settings → Security & Privacy → Authentication and User Provisioning, then select Configure on the SAML row
  2. Select your identity provider from the 24+ supported providers
  3. Configure the SAML connection following your IdP’s instructions
  4. Map IdP groups to Vercel roles (vercel-role-owner, vercel-role-member, etc.)

Step 2: Enforce SAML

  1. After confirming SSO works: Toggle Enforce SAML to ON
  2. Distribute custom login URL: https://vercel.com/login?saml=<team_id>
  3. Verify session duration is 24 hours (default – re-authentication required after)

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-1.01-enforce-sso-with-saml.tf View source on GitHub ↗
# ONE vercel_team_config per team: the provider sends every attribute from its
# own plan on update, so a second resource for the same team would revert this
# one. Team-level settings from other controls (6.1) are merged in via locals.
resource "vercel_team_config" "hardened" {
  id = var.vercel_team_id

  # Enterprise, or Pro with the SAML add-on. Confirm IdP login works BEFORE
  # enforcing, or members are locked out.
  saml = {
    enforced = var.saml_enforced
  }

  # 6.1 (L2): team-wide Sensitive env var policy and IP privacy; null at L1
  # leaves the setting unmanaged.
  sensitive_environment_variable_policy = local.sensitive_env_policy
  hide_ip_addresses                     = local.hide_ip_addresses
  hide_ip_addresses_in_log_drains       = local.hide_ip_addresses
}

Validation & Testing

  1. Attempt login without SAML – should be blocked when enforcement is ON
  2. Login via IdP – should succeed and land on team dashboard
  3. Remove user from IdP group – should lose Vercel access within sync interval

Expected result: Only IdP-authenticated users can access the Vercel team

Monitoring & Maintenance

  • Monthly: Review SAML configuration and IdP group mappings
  • Quarterly: Audit active sessions and SAML enforcement status
  • On event: Re-verify after IdP changes or Vercel plan changes

Operational Impact

Aspect Impact Level Details
User Experience Medium Users must authenticate via IdP; custom login URL required
System Performance None No performance impact
Maintenance Burden Low Managed by IdP; Vercel config rarely changes
Rollback Difficulty Easy Toggle enforcement OFF in Team Settings

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical and physical access controls
NIST 800-53 IA-2(1) Multi-factor authentication
ISO 27001 A.9.4.2 Secure log-on procedures
PCI DSS 8.3.1 Multi-factor authentication for all access

1.2 Configure Directory Sync (SCIM)

Profile Level: L2 (Walk)

NIST 800-53: AC-2, IA-5(1)

Description

Enable SCIM-based directory synchronization to automatically provision and deprovision team members from your identity provider.

Rationale

Why This Matters:

  • Eliminates manual user lifecycle management
  • Ensures immediate deprovisioning when employees leave
  • Enforces consistent role assignments across the organization

Attack Prevented: Orphaned accounts, delayed deprovisioning, unauthorized persistent access

Prerequisites

  • Vercel Enterprise plan
  • SAML SSO configured (Section 1.1)
  • IdP supports SCIM (Okta, Entra ID, etc.)

ClickOps Implementation

Step 1: Enable Directory Sync

  1. Navigate to: Team Settings → Security & Privacy → Authentication and User Provisioning, then select Configure on the Directory Sync row
  2. Generate SCIM endpoint URL and bearer token
  3. Configure your IdP with the SCIM endpoint

Step 2: Map Groups to Roles

  1. Create IdP groups matching Vercel roles: vercel-role-owner, vercel-role-member, vercel-role-developer, vercel-role-security, vercel-role-billing
  2. Map IdP groups to Access Groups for project-level permissions
  3. Ensure at least one owner mapping exists to prevent lockout

Time to Complete: ~45 minutes

Code Implementation

Code Pack: API Script
hth-vercel-1.02-configure-directory-sync.sh View source on GitHub ↗
# curl -f: an HTTP 4xx/5xx aborts before any output is printed as a finding.
vercel_get() {
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" "https://api.vercel.com$1"
}

# --- Verify team SAML/SCIM configuration ---
echo "=== Directory Sync Configuration ==="
vercel_get "/v2/teams/${VERCEL_TEAM_ID}" | \
  jq '{name, saml, remoteCaching, membership}'

# --- Every team member, all pages ---
MEMBERS_NDJSON=""
UNTIL=""
COMPLETE=0
for _page in $(seq 1 100); do
  PAGE="$(vercel_get "/v3/teams/${VERCEL_TEAM_ID}/members?limit=100${UNTIL:+&until=${UNTIL}}")"
  MEMBERS_NDJSON+="$(printf '%s' "${PAGE}" | jq -c '.members[]')"$'\n'
  HAS_NEXT="$(printf '%s' "${PAGE}" | jq -r 'if (.pagination | has("hasNext"))
      then (.pagination.hasNext | tostring)
      else ((.pagination.next != null) and ((.members | length) >= 100) | tostring) end')"
  if [ "${HAS_NEXT}" != "true" ]; then
    COMPLETE=1
    break
  fi
  NEXT="$(printf '%s' "${PAGE}" | jq -r '.pagination.next // empty')"
  if [ -z "${NEXT}" ] || [ "${NEXT}" = "${UNTIL}" ]; then
    break
  fi
  UNTIL="${NEXT}"
done
if [ "${COMPLETE}" -ne 1 ]; then
  echo "ERROR: team member pagination did not complete -- the member and owner lists would be partial (exit 2)." >&2
  exit 2
fi
MEMBERS_JSON="$(printf '%s' "${MEMBERS_NDJSON}" | jq -s 'unique_by(.uid)')"

echo ""
echo "=== Current Team Members ($(printf '%s' "${MEMBERS_JSON}" | jq 'length')) ==="
printf '%s' "${MEMBERS_JSON}" | jq '.[] | {uid, email, role, joinedFrom}'

# --- Audit members for role compliance ---
echo ""
echo "=== Members with Owner Role (should be minimal) ==="
printf '%s' "${MEMBERS_JSON}" | jq '.[] | select(.role == "OWNER") | {uid, email}'

# --- Verify Access Groups exist (Enterprise), all pages ---
echo ""
echo "=== Access Groups ==="
CURSOR=""
for _page in $(seq 1 100); do
  GROUPS_JSON="$(vercel_get "/v1/access-groups?teamId=${VERCEL_TEAM_ID}&limit=100${CURSOR:+&next=${CURSOR}}")"
  if [ "$(printf '%s' "${GROUPS_JSON}" | jq 'has("accessGroups")')" != "true" ]; then
    echo "(the response carries no accessGroups list -- Access Groups are not enabled for this team)"
    break
  fi
  printf '%s' "${GROUPS_JSON}" | jq '.accessGroups[] | {name, membersCount, projectsCount}'
  CURSOR="$(printf '%s' "${GROUPS_JSON}" | jq -r '.pagination.next // empty | @uri')"
  [ -n "${CURSOR}" ] || break
done
if [ -n "${CURSOR}" ]; then
  echo "ERROR: more than 100 pages of access groups -- the list above is partial (exit 2)." >&2
  exit 2
fi

Validation & Testing

  1. Add a test user in IdP – should appear in Vercel team within sync interval
  2. Remove test user from IdP group – should lose Vercel access
  3. Change user role in IdP – should reflect in Vercel

Expected result: Team membership mirrors IdP directory state

Operational Impact

Aspect Impact Level Details
User Experience Low Transparent to end users
System Performance None No performance impact
Maintenance Burden Low Fully automated after setup
Rollback Difficulty Moderate Must manually manage members if disabled

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.2 Prior to issuing system credentials, verify identity
NIST 800-53 AC-2 Account management
ISO 27001 A.9.2.1 User registration and de-registration
PCI DSS 8.1.3 Immediately revoke access for terminated users

1.3 Enforce Least-Privilege RBAC

Profile Level: L1 (Crawl)

NIST 800-53: AC-3, AC-6

Description

Configure team and project-level role-based access control using Vercel’s granular role system and Access Groups.

Rationale

Why This Matters:

  • Prevents over-privileged access to production environments
  • Developers cannot modify production environment variables without explicit elevation
  • Security role enables firewall management without deployment access

Attack Prevented: Insider threat, privilege escalation, unauthorized production modifications

Vercel Role Summary:

Role Deploy Prod Env Vars Billing Firewall Members
Owner Yes Yes Yes Yes Yes
Member Yes Yes No No No
Developer Yes No No No No
Security No No No Yes No
Billing No No Yes No No
Viewer No No No No No
Contributor Per-project Per-project No No No

ClickOps Implementation

Step 1: Audit Current Roles

  1. Navigate to: Team Settings → Members
  2. Review all members and their assigned roles
  3. Identify over-privileged accounts (Owners who should be Members, etc.)

Step 2: Implement Least Privilege

  1. Downgrade accounts to minimum required role
  2. Use Contributor role + project-level assignments for granular access
  3. Create Access Groups for team-based project permissions
  4. Assign Permission Groups additively (Create Project, Full Production Deployment, etc.)

Step 3: Configure Access Groups (Enterprise)

  1. Navigate to: Team Settings → Access Groups
  2. Create groups aligned to team structure (e.g., “Frontend Team”, “Platform Team”)
  3. Assign projects with appropriate roles (Admin, Developer, Viewer)
  4. Link to Directory Sync groups if SCIM is configured

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-1.03-enforce-least-privilege-rbac.tf View source on GitHub ↗
# --- L1: Manage team members with least-privilege roles ---
resource "vercel_team_member" "members" {
  for_each = var.team_members

  team_id = var.vercel_team_id
  email   = each.value.email
  role    = each.value.role
}

# --- L2: Create Access Groups for project-level permissions (Enterprise) ---
resource "vercel_access_group" "groups" {
  for_each = var.profile_level >= 2 ? var.access_groups : {}

  team_id = var.vercel_team_id
  name    = each.key
}

# --- L2: Link Access Groups to projects ---
resource "vercel_access_group_project" "assignments" {
  for_each = var.profile_level >= 2 ? var.access_group_projects : {}

  team_id         = var.vercel_team_id
  access_group_id = vercel_access_group.groups[each.value.group_name].id
  project_id      = each.value.project_id
  role            = each.value.role
}

Validation & Testing

  1. Developer role cannot modify production environment variables
  2. Security role can manage firewall but cannot deploy
  3. Viewer role has read-only access with no deploy capability
  4. Contributor role has no access until explicitly assigned to a project

Expected result: Each team member has minimum required permissions

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.3 Logical access controls, role-based access
NIST 800-53 AC-3, AC-6 Access enforcement, least privilege
ISO 27001 A.9.1.2 Access to networks and network services
PCI DSS 7.1 Limit access to system components

1.4 Harden API Token Lifecycle

Profile Level: L1 (Crawl)

NIST 800-53: IA-5, IA-4

Description

Enforce scoped, time-limited API tokens and replace long-lived credentials with OIDC federation where possible.

Rationale

Why This Matters:

  • Vercel now enforces 90-day maximum lifetime on granular tokens
  • Classic tokens have been revoked platform-wide
  • OIDC federation eliminates static credentials entirely for cloud provider access
  • 2FA is required by default for token creation

Attack Prevented: Token theft, credential leakage in CI/CD logs, unauthorized API access

ClickOps Implementation

Step 1: Audit Existing Tokens

  1. Navigate to: Account Settings → Tokens
  2. Review all active tokens for scope and expiration
  3. Delete unused or overly-scoped tokens

Step 2: Create Scoped Tokens

  1. Create new tokens with minimum required scopes
  2. Set expiration to shortest practical duration (max 90 days)
  3. Use descriptive names indicating purpose (e.g., “github-actions-deploy”)

Step 3: Implement OIDC Federation (Preferred)

  1. For each project, navigate to: Project Settings → Security → Secure backend access with OIDC federation
  2. Set issuer mode to Team (recommended over Global) and select Save
  3. Configure cloud provider trust policies (AWS, GCP, Azure)
  4. Replace static credentials in environment variables with OIDC token references

Time to Complete: ~30 minutes

Code Implementation

Code Pack: API Script
hth-vercel-1.04-harden-api-token-lifecycle.sh View source on GitHub ↗
# curl -f: an HTTP 4xx/5xx (bad token, missing scope) aborts the audit instead
# of piping an error body into jq and printing nulls as if they were findings.
vercel_get() {
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" "https://api.vercel.com$1"
}

# --- Audit existing tokens (name, type, age, expiry -- never token material) ---
echo "=== Auditing Vercel API Tokens ==="
TOKENS_JSON="$(vercel_get "/v6/user/tokens?limit=100")"
# listAuthTokens answers with pagination{count,next,prev}, but the spec documents
# no parameter that requests the next page (the first-party CLI also stops at
# `--limit` 100). A second page therefore cannot be audited: fail closed rather
# than report a partial token list as complete.
if [ "$(printf '%s' "${TOKENS_JSON}" | jq -r '.pagination.next // empty')" != "" ]; then
  echo "ERROR: /v6/user/tokens returned more than one page -- the token audit would be partial (exit 2)." >&2
  exit 2
fi
echo "${TOKENS_JSON}" | jq '.tokens[] | {id, name, type, origin, scopes, activeAt, expiresAt}'

# --- Tokens with no expiration (security risk) ---
echo ""
echo "=== Tokens Without Expiration (ACTION REQUIRED) ==="
echo "${TOKENS_JSON}" | jq '.tokens[] | select(.expiresAt == null) | {id, name, createdAt}'

# --- Tokens Vercel has flagged as leaked ---
echo ""
echo "=== Tokens Flagged as Leaked (revoke immediately) ==="
echo "${TOKENS_JSON}" | jq '.tokens[] | select(.leakedAt != null) | {id, name, leakedAt}'

# --- OIDC federation is configured per PROJECT (oidcTokenConfig) ---
if [ -n "${VERCEL_PROJECT_ID:-}" ]; then
  echo ""
  echo "=== OIDC Federation Status for ${VERCEL_PROJECT_ID} ==="
  vercel_get "/v9/projects/${VERCEL_PROJECT_ID}?teamId=${VERCEL_TEAM_ID}" | \
    jq '{oidcTokenConfig}'
fi

Validation & Testing

  1. No tokens exist with unlimited expiration
  2. OIDC federation provides short-lived credentials (60-min TTL)
  3. All CI/CD pipelines use scoped tokens or OIDC
  4. Token creation requires 2FA

Expected result: No long-lived, overly-scoped tokens; OIDC for cloud provider access

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 IA-5 Authenticator management
ISO 27001 A.9.2.4 Management of secret authentication information
PCI DSS 8.2.4 Change user passwords/passphrases at least every 90 days

1.5 Audit Third-Party Integrations and OAuth Grants

Profile Level: L1 (Crawl)

NIST 800-53: AC-6, SA-12, CM-8

Description

Maintain an inventory of all Marketplace integrations, Git connections, deploy hooks, and third-party OAuth grants that can act against the Vercel team, and review them quarterly. Extend the audit into the identity providers (Google Workspace, GitHub org, Microsoft Entra, Slack) that issue OAuth trust to Vercel-adjacent vendors.

Rationale

Why This Matters:

  • Third-party OAuth relationships are invisible to most security tooling and are not detected by Vercel’s platform monitoring
  • A compromised vendor with OAuth access to your identity provider can pivot into systems that trust it (including Vercel)
  • Marketplace integrations and deploy hooks act with elevated team privileges even after the installer leaves

Attack Prevented: Vendor-to-vendor OAuth supply-chain compromise, orphaned integration privileges, deploy-hook URL leakage.

Real-World Incidents:

  • Vercel April 2026 incident: Lumma Stealer on a Context.ai employee laptop stole Google Workspace OAuth tokens. The attacker used that OAuth trust to hijack a Vercel employee’s Google Workspace account and enumerate customer non-sensitive environment variables. Customers with no direct relationship to Context.ai were affected. (Vercel KB Bulletin, Trend Micro analysis)

Prerequisites

  • Team Owner access in Vercel
  • Admin access to Google Workspace, GitHub, Microsoft Entra, and any other OAuth issuers used by your organization
  • Vercel API token with read scope

ClickOps Implementation

Step 1: Vercel-side Inventory

  1. Navigate to: Team Settings → Integrations – list all installed Marketplace integrations and the projects each has access to. Remove anything unused.
  2. Navigate to: Team Settings → Git – review connected Git namespaces. Remove stale installations.
  3. For each project: Project Settings → Git – confirm the Vercel GitHub App is scoped to specific repositories rather than entire organizations.
  4. For each project: Project Settings → Git → Deploy Hooks – list all hooks, rotate any older than 90 days, and confirm each hook URL is stored in your secrets manager (not git).

Step 2: Identity-Provider-side Audit (quarterly)

  1. Google Workspace: admin.google.com → Security → API Controls → Third-party app access. Revoke unrecognized apps and any Drive-permissioned apps that are not business-critical.
  2. GitHub Organization: github.com/organizations/<org>/settings/oauth_application_policy. Review installed OAuth apps and GitHub Apps. Restrict the Vercel GitHub App to specific repositories.
  3. Microsoft Entra ID: entra.microsoft.com → Enterprise applications. Review consented permissions for each Enterprise application.
  4. Slack: <workspace>.slack.com/apps/manage → Installed apps. Audit scopes per app; remove unused integrations.

Time to Complete: ~60 minutes (initial), ~20 minutes (quarterly review)

Code Implementation

Code Pack: API Script
hth-vercel-1.05-audit-third-party-integrations.sh View source on GitHub ↗
# curl -f: an HTTP 4xx/5xx aborts the audit instead of printing an empty inventory.
vercel_get() {
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" "https://api.vercel.com$1"
}

# --- Installed Vercel Marketplace integrations (response is a JSON array) ---
echo "=== Installed Vercel Integrations ==="
vercel_get "/v1/integrations/configurations?view=account&teamId=${VERCEL_TEAM_ID}" | \
  jq '.[] | {id, integrationId, slug, status, projects: (.projects // [] | length), scopes, createdAt, ownerId}'

# --- Connected Git namespaces (GitHub/GitLab/Bitbucket) ---
echo ""
echo "=== Connected Git Accounts (review for unused or stale links) ==="
vercel_get "/v1/integrations/git-namespaces" | \
  jq '.[] | {provider, name, slug, installationId, isAccessRestricted}'

# --- One project listing, EVERY page, feeds the three per-project audits below.
#     A walk that cannot finish exits 2 rather than audit a partial list. ---
PROJECTS='[]'
cursor=""
pages=0
while :; do
  pages=$((pages + 1))
  if [ "${pages}" -gt 100 ]; then
    echo "ERROR: /v10/projects paging did not finish within 100 pages -- the audit would be partial (exit 2)." >&2
    exit 2
  fi
  page="$(vercel_get "/v10/projects?teamId=${VERCEL_TEAM_ID}&limit=100${cursor}")"
  merged="$(printf '%s\n%s\n' "${PROJECTS}" "${page}" | jq -c -s '
    (reduce .[0][] as $p ({}; .[$p.id] = true)) as $seen
    | [.[1].projects[] | {id, name, gitForkProtection} | select($seen[.id] | not)] as $new
    | {projects: (.[0] + $new), added: ($new | length)}')"
  PROJECTS="$(printf '%s' "${merged}" | jq -c '.projects')"
  next_type="$(printf '%s' "${page}" | jq -r '.pagination.next | type')"
  [ "${next_type}" = "null" ] && break
  if [ "$(printf '%s' "${merged}" | jq -r '.added')" -eq 0 ]; then
    echo "ERROR: /v10/projects page ${pages} added no new project although pagination.next is set -- paging did not advance (exit 2)." >&2
    exit 2
  fi
  case "${next_type}" in
    number) cursor="&until=$(printf '%s' "${page}" | jq -r '.pagination.next')" ;;
    string) cursor="&from=$(printf '%s' "${page}" | jq -r '.pagination.next | @uri')" ;;
    *) echo "ERROR: unexpected pagination.next type (${next_type}) from /v10/projects (exit 2)." >&2; exit 2 ;;
  esac
done
echo ""
echo "=== Projects audited: $(printf '%s' "${PROJECTS}" | jq 'length') (${pages} page(s)) ==="

echo ""
echo "=== Projects WITHOUT Git Fork Protection (review immediately) ==="
printf '%s' "${PROJECTS}" | \
  jq '.[] | select(.gitForkProtection != true) | {id, name, gitForkProtection}'

# --- Per project (GET /v9/projects/{id}): deploy hooks, Deployment Protection,
#     and Protection Bypass for Automation. Each hook URL is an unauthenticated
#     deploy trigger, so the URL itself is never printed. ---
echo ""
echo "=== Deploy Hooks and Deployment Protection per Project ==="
PROJECT_IDS="$(printf '%s' "${PROJECTS}" | jq -r '.[].id')"
for project_id in ${PROJECT_IDS}; do
  project_json="$(vercel_get "/v9/projects/${project_id}?teamId=${VERCEL_TEAM_ID}")"
  echo "${project_json}" | jq '{
    id, name,
    deployHooks: [.link.deployHooks[]? | {id, name, ref, createdAt}],
    vercelAuthentication: .ssoProtection.deploymentType,
    passwordProtection: .passwordProtection.deploymentType,
    automationBypassEnabled: ((.protectionBypass // {}) | length > 0)
  }'
done

Validation & Testing

  1. All Marketplace integrations have a business owner on record
  2. The Vercel GitHub App is scoped to specific repositories (not org-wide) for every connection
  3. Every deploy hook URL is stored in a secrets manager and rotated ≤90 days ago
  4. No unrecognized third-party OAuth grants exist in Google Workspace, GitHub, Entra, or Slack

Expected result: Every OAuth-trust relationship touching Vercel is explicitly authorized, scoped, and rotated on a schedule.

Monitoring & Maintenance

  • Quarterly: Re-run the full inventory script and identity-provider audit
  • On event: Re-audit immediately after any vendor security advisory affecting an OAuth-connected service
  • On event: Rotate all deploy hooks and Vercel API tokens when a Vercel employee or any team member with admin access to a connected identity provider leaves

Operational Impact

Aspect Impact Level Details
User Experience None Background audit — no end-user impact
System Performance None Read-only API calls
Maintenance Burden Medium Quarterly human review required
Rollback Difficulty Easy Inventory is read-only; remediation actions are independently reversible

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC9.2 Logical access controls, vendor management
NIST 800-53 AC-6, CM-8, SA-12 Least privilege, information system component inventory, supply chain protection
ISO 27001 A.15.2.1, A.9.2.5 Monitoring and review of supplier services, review of user access rights
PCI DSS 12.8.2, 12.8.4 Maintain service provider list; monitor service provider compliance

2. Deployment Security

2.1 Configure Deployment Protection

Profile Level: L1 (Crawl)

NIST 800-53: CM-3, AC-3

Description

Enable multi-layered deployment protection using Vercel Authentication, password protection, trusted IPs, or Passport (your own IdP) to prevent unauthorized access to preview and production deployments.

Rationale

Why This Matters:

  • Preview deployments can expose unreleased features, staging credentials, and internal APIs
  • Unprotected preview URLs are indexed by search engines and discoverable by attackers
  • Production environment variables can leak through unprotected preview deployments

Attack Prevented: Unauthorized access to staging environments, information disclosure via preview URLs, credential harvesting from preview deployments

Attack Scenario: Attacker discovers *.vercel.app preview URL via DNS enumeration, accesses unprotected preview with staging database credentials exposed in client-side code.

Protection Methods × Scopes

Vercel documents four protection methods and four protection scopes. Choose one method and one scope per project.

Methods:

Method Plans Notes
Vercel Authentication Hobby, Pro, Enterprise Requires team login; covers Routing Middleware
Password Protection Pro ($20/month per protected project) or Enterprise (included for every project at the team level); not available on Hobby Pro teams that bought the legacy Advanced Deployment Protection package keep team-level Password Protection on that billing (Vercel: Deployment Protection pricing)
Trusted IPs Enterprise only IPv4 CIDR allowlist
Passport Enterprise only (account-team pricing) Restricts deployment access to visitors authenticated against your own IdP (Entra ID, Okta, any OIDC provider) — see below

Passport (Enterprise): where Vercel Authentication gates on Vercel team membership, Passport gates on membership in your identity provider. You register a Vercel Connect OAuth/OIDC application that stores the issuer, authorization/token endpoints, and client ID/secret; Vercel validates the IdP response and sets a deployment-scoped session cookie for the visitor. Use it when the audience that must reach a protected deployment is your workforce or a customer directory rather than your Vercel team. Successful authentications are recorded as passport-access-granted events in both the Activity Log and Audit Logs (see Section 8.2).

Scopes:

Scope Plans What it covers
Standard Protection All Preview URLs and generated deployment URLs. Every production domain stays public: custom domains and the project’s auto-assigned <project>.vercel.app domain (Vercel: Standard Protection)
All Deployments All (included on Hobby, Pro, and Enterprise) Preview + production + generated URLs
Only Production Deployments (via Trusted IPs) Enterprise only Production domain only; preview stays public
(Legacy) Standard / Pre-Production All Retained for backwards compatibility — migrate to current scopes

Hobby-plan caveat: Vercel Authentication + Standard Protection on Hobby protects preview and generated deployment URLs, but every production domain, including the auto-assigned <project>.vercel.app, remains public. To gate production as well, select All Deployments, which Vercel now includes on every plan, Hobby included (see 2.4).

ClickOps Implementation

Step 1: Set Team Default for New Projects

  1. Navigate to: Team Settings → Deployment Protection
  2. Configure the team default protection method + scope so new projects inherit the hardened baseline
  3. Individual projects can override the default when legitimately required

Step 2: Enable Standard Protection on Each Existing Project (L1, All Plans)

  1. Navigate to: Project Settings → Deployment Protection
  2. Select scope Standard Protection and method Vercel Authentication
    • The API reports this scope as ssoProtection.deploymentType = all_except_custom_domains (Terraform: standard_protection_new). In that name, “custom domains” includes the auto-assigned <project>.vercel.app: it stays public too.
  3. Note: Deployment Protection applies to Routing Middleware requests as well — automation that depends on reaching middleware without auth will need a bypass token

Step 3: Add Password Protection (L2 — Pro at $20/month per project, or Enterprise)

  1. Enable Password Protection for the appropriate scope (on Pro, enabling it adds the per-project charge; disabling it stops future charges)
  2. Set a strong password and rotate quarterly; distribute via secrets manager, never in docs
  3. For shareable-link scenarios use Deployment Protection Exceptions (included on every plan) rather than disabling protection

Step 4: Configure Trusted IPs (L3 — Enterprise)

  1. Add office and VPN egress IP ranges as trusted IPs
  2. Set protection mode to Trusted IP Required
  3. Apply to All Deployments for maximum protection — or to Only Production Deployments if previews must stay publicly accessible

Step 5: Harden Protection Bypass for Automation

  1. Navigate to: Project Settings → Deployment Protection → Protection Bypass for Automation
  2. If required, generate a bypass secret of 32+ random characters; exposed to builds via VERCEL_AUTOMATION_BYPASS_SECRET
  3. Callers may present the secret via header x-vercel-protection-bypass or query parameter ?x-vercel-protection-bypass=... (query form is required for Slack/Stripe webhook URL verification that cannot set headers)
  4. For iframe scenarios, add query parameter ?x-vercel-set-bypass-cookie=samesitenone
  5. Bypass does not override active DDoS mitigations, rate limits during attacks, or attack-triggered challenges — defense-in-depth is preserved
  6. Regenerating or deleting a bypass secret invalidates previously-deployed builds; a redeploy is required to take effect
  7. If Passport is the active method, send the bypass secret on the original request — Passport runs before deployment routes and before any Next.js proxy function, so a secret injected downstream by your application never reaches the check
  8. L3: Disable automation bypass entirely if not required

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-2.01-configure-deployment-protection.tf View source on GitHub ↗
# --- Adopt the EXISTING project. vercel_project CREATES a project when it is
#     not imported, so every project-level control (2.1, 2.2, 2.4, 2.5) feeds
#     this ONE resource instead of declaring its own vercel_project. ---
import {
  to = vercel_project.hardened
  id = "${var.vercel_team_id}/${var.project_id}"
}

# --- The project as it is now. Adopting it must never weaken a protection it
#     already has; the preconditions and fallbacks below read this. ---
data "vercel_project" "current" {
  name    = var.project_name
  team_id = var.vercel_team_id
}

locals {
  # null when the project has no such protection today
  current_password_scope    = try(data.vercel_project.current.password_protection.deployment_type, null)
  current_trusted_ips_scope = try(data.vercel_project.current.trusted_ips.deployment_type, null)
  current_vercel_auth_scope = try(data.vercel_project.current.vercel_authentication.deployment_type, null)
}

resource "vercel_project" "hardened" {
  name    = var.project_name
  team_id = var.vercel_team_id

  # L1: Standard Protection + Vercel Authentication (all plans).
  # "standard_protection_new" is the current Standard Protection (API:
  # all_except_custom_domains), which leaves every production domain public,
  # including the auto-assigned <project>.vercel.app; "standard_protection" is
  # the (Legacy) scope, which leaves production deployment URLs public.
  # "all_deployments" is the 2.4 scope (included on every plan).
  vercel_authentication = {
    deployment_type = local.vercel_authentication_scope
  }

  # L2: Password Protection (Enterprise, or Pro at $20/mo per project) -- previews, or all
  # deployments when 2.4 is enabled
  password_protection = var.profile_level >= 2 && var.preview_password != "" ? {
    deployment_type = local.password_protection_scope
    password        = var.preview_password
  } : null

  # L3: Trusted IPs restrict access to known networks (Enterprise)
  trusted_ips = var.profile_level >= 3 && length(var.trusted_ip_addresses) > 0 ? {
    addresses       = var.trusted_ip_addresses
    deployment_type = local.trusted_ips_scope
    protection_mode = "trusted_ip_required"
  } : null

  # L2: no preview deployments (2.2); skew protection on. Below L2 the
  # project's current values are kept, never planned back to "off".
  preview_deployments_disabled = local.preview_deployments_disabled
  skew_protection              = var.profile_level >= 2 ? "12 hours" : data.vercel_project.current.skew_protection
  prioritise_production_builds = true

  # 2.2 Harden Git Integration
  git_fork_protection  = local.git_fork_protection
  git_provider_options = local.git_provider_options

  # 2.5 Protected Source Maps
  protected_sourcemaps = local.protected_sourcemaps

  lifecycle {
    # Settings this pack does not own keep the values the project already has.
    # Without this list, adopting the project plans each of them to null, and
    # the provider sends those nulls on update: a null git_repository unlinks
    # the Git repository, and the framework, root directory and build commands
    # reset to their defaults.
    ignore_changes = [
      git_repository, framework, root_directory, build_command, dev_command,
      install_command, ignore_command, output_directory, preview_deployment_suffix,
      public_source, environment, trusted_sources, options_allowlist,
      oidc_token_config, git_comments, serverless_function_region, node_version,
      resource_config, on_demand_concurrent_builds, build_machine_type,
      automatically_expose_system_environment_variables, auto_assign_custom_domains,
      enable_affected_projects_deployments, enable_preview_feedback,
      enable_production_feedback, preview_comments, git_lfs, function_failover,
      customer_success_code_visibility, directory_listing,
    ]

    precondition {
      condition     = data.vercel_project.current.id == var.project_id
      error_message = "project_name is not the current name of project_id; the apply would rename project_id. Set project_name to that project's name."
    }
    precondition {
      condition     = local.current_password_scope == null || (var.profile_level >= 2 && var.preview_password != "")
      error_message = "The project already has Password Protection. Set profile_level >= 2 and preview_password, or this apply removes it."
    }
    precondition {
      condition     = local.current_trusted_ips_scope == null || (var.profile_level >= 3 && length(var.trusted_ip_addresses) > 0)
      error_message = "The project already has Trusted IPs. Set profile_level = 3 and trusted_ip_addresses to the ranges to keep, or this apply removes them."
    }
    precondition {
      condition     = local.current_vercel_auth_scope != "all_deployments" || local.vercel_authentication_scope == "all_deployments"
      error_message = "Vercel Authentication already covers All Deployments. Set profile_level >= 2 and private_production_deployments_enabled = true, or this apply narrows it to Standard Protection."
    }
  }
}

# --- L3: Protection Bypass for Automation stays disabled: a bypass secret
#     exists only if a vercel_project_protection_bypass resource creates one,
#     and this pack declares none. ---

Validation & Testing

  1. Unauthenticated access to preview URL returns login prompt
  2. Password-protected deployment requires correct password
  3. Access from non-trusted IP is blocked (Enterprise)
  4. Automation bypass secret is 32+ characters if enabled
  5. Requests to Routing Middleware without auth are rejected at the edge
  6. Team default for new projects matches the hardened baseline

Expected result: All non-production deployments require authentication; team default enforces the baseline.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical and physical access controls
NIST 800-53 CM-3, AC-3 Configuration change control, access enforcement
ISO 27001 A.14.2.5 Secure system engineering principles
PCI DSS 6.4.1 Separate development/test from production

2.2 Harden Git Integration

Profile Level: L1 (Crawl)

NIST 800-53: CM-7, SA-10

Description

Secure the Git integration pipeline to prevent unauthorized deployments from forks, unverified commits, and compromised repositories.

Rationale

Why This Matters:

  • Fork-based deployments can inject malicious code into your deployment pipeline
  • Unverified commits may contain unauthorized changes
  • Unrestricted deployment triggers enable supply chain attacks

Attack Prevented: Supply chain injection via forks, unauthorized code deployment, commit impersonation

ClickOps Implementation

Step 1: Enable Fork Protection

  1. Navigate to: Project Settings → Git
  2. Ensure Git Fork Protection is enabled (blocks deployments from forked repos without approval)

Step 2: Restrict Deployment Creation (L2)

  1. Set Create Deployments to Only Production – prevents preview deployments from PRs
  2. Or set to Disabled for fully manual deployment control

Step 3: Require Verified Commits (L2)

  1. Enable Require Verified Commits in Git provider options
  2. Configure commit signing in your Git provider (GPG or SSH keys)

Step 4: Review Connected Repositories

  1. Navigate to: Team Settings → Integrations
  2. Audit all connected Git repositories
  3. Remove access to repositories no longer in use
  4. Limit repository access to specific repos rather than full organization access

Time to Complete: ~10 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-2.02-harden-git-integration.tf View source on GitHub ↗
# Applied through vercel_project.hardened (hth-vercel-2.01).
locals {
  # L1: block deployments from forked repositories without approval
  git_fork_protection = var.git_fork_protection_enabled

  # L2: Git pushes create production deployments only (no PR previews).
  # In provider 5.x git_provider_options.create_deployments is a bool that
  # switches ALL Git deployments on or off, so it is left unmanaged here.
  # Below L2 the project's current setting is kept: planning false would
  # re-enable preview deployments on a project that had them off.
  preview_deployments_disabled = var.profile_level >= 2 || data.vercel_project.current.preview_deployments_disabled == true

  git_provider_options = {
    # L2: require verified (signed) commits. Never switched off by this pack.
    require_verified_commits = (var.profile_level >= 2 && var.require_verified_commits) || try(data.vercel_project.current.git_provider_options.require_verified_commits, false) == true
  }
}

Validation & Testing

  1. Fork deployment is blocked without explicit approval
  2. Unsigned commits fail deployment (when verified commits enabled)
  3. Only authorized repositories are connected
  4. Deployment creation restricted to production-only (L2)

Expected result: Deployment pipeline only accepts authorized, verified code

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC8.1 Change management controls
NIST 800-53 CM-7, SA-10 Least functionality, developer security testing
ISO 27001 A.14.2.2 System change control procedures
PCI DSS 6.3.2 Review custom code prior to release

2.3 Configure Rolling Releases

Profile Level: L2 (Walk)

NIST 800-53: CM-3(2)

Description

Enable progressive deployment rollouts to limit blast radius of production changes. Pair with Skew Protection so client code and backend APIs always come from the same deployment revision.

Rationale

Why This Matters:

  • Full instant deployments expose 100% of traffic to potential issues
  • Rolling releases enable canary-style testing with real production traffic
  • Manual approval gates add human verification before full rollout

Attack Prevented: Blast radius of compromised deployments, rapid exploitation of deployed vulnerabilities, client/server version mismatch during rollout.

Security Caveats from Vercel Docs

  • Skew Protection is required for defense in depth. Without it, a user can fetch a page from one deployment and send API calls that are served by the other — breaking invariants that security code depends on.
  • 0% canaries are not securely hidden. Any visitor can force the canary deployment by appending ?vcrrForceCanary=true to a URL. Do not use 0% stages to stage secret pre-release changes; use Deployment Protection Exceptions instead.
  • The vcrrForceStable=true / vcrrForceCanary=true query parameters are honored by Vercel edge and write a cookie. Treat traffic from these parameters as attacker-controllable; do not use them as a trust signal.

ClickOps Implementation

Step 1: Enable Skew Protection (Prerequisite)

  1. Navigate to: Project Settings → Advanced → Skew Protection
  2. Set maximum skew window to 12 hours or the minimum your deployment cadence supports

Step 2: Configure Rolling Release

  1. Navigate to: Project Settings → Build & Deployment → Rolling Releases
  2. Choose Manual Approval for production deployments
  3. Configure stages (e.g., 5% → 25% → 100%) — the last stage must always be 100%
  4. Set duration for automatic advancement if using automatic mode

Step 3: Document Rollback Path

  1. Confirm Instant Rollback is available from the Deployments page or REST API (POST /v1/projects/{projectId}/rollback/{deploymentId})
  2. Rehearse the rollback procedure with the on-call team at least once per quarter

Time to Complete: ~15 minutes

Code Implementation

Code Pack: API Script
hth-vercel-2.03-configure-rolling-releases.sh View source on GitHub ↗
# curl -f: an HTTP 4xx/5xx aborts the audit instead of reporting nulls.
vercel_get() {
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" "https://api.vercel.com$1"
}

PROJECT_JSON="$(vercel_get "/v9/projects/${VERCEL_PROJECT_ID}?teamId=${VERCEL_TEAM_ID}")"

# --- Project deployment configuration ---
echo "=== Project Deployment Configuration ==="
echo "${PROJECT_JSON}" | jq '{name, framework, skewProtectionMaxAge, rollingRelease}'

# --- Rolling release configuration (stages, target, approval gate) ---
echo ""
echo "=== Rolling Release Configuration ==="
vercel_get "/v1/projects/${VERCEL_PROJECT_ID}/rolling-release/config?teamId=${VERCEL_TEAM_ID}" | \
  jq '.rollingRelease'

# --- Recent deployments ---
echo ""
echo "=== Recent Deployments ==="
vercel_get "/v7/deployments?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}&limit=5" | \
  jq '.deployments[] | {uid, state, target, createdAt, commit: .meta.githubCommitMessage}'

# --- Skew Protection is the prerequisite for a safe rollout ---
echo ""
echo "=== Skew Protection Status ==="
if [ "$(echo "${PROJECT_JSON}" | jq -r '.skewProtectionMaxAge // empty')" = "" ]; then
  echo "NOT CONFIGURED: skewProtectionMaxAge is unset -- enable Skew Protection first."
  exit 1
fi
echo "${PROJECT_JSON}" | jq '{skewProtectionMaxAge}'

Validation & Testing

  1. New deployment starts at first stage percentage
  2. Manual approval required before advancing (if configured)
  3. Skew Protection prevents client/server mismatch for the configured window
  4. Rollback available at any stage
  5. 0% canary stages are treated as accessible to the public (not a privacy boundary)

Expected result: Production deployments roll out progressively with approval gates, paired with Skew Protection.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC8.1 Change management process
NIST 800-53 CM-3(2) Testing, validation, and documentation of changes
ISO 27001 A.14.2.9 System acceptance testing
PCI DSS 6.4.5 Change control procedures

2.4 Private Production Deployments (Advanced Deployment Protection)

Profile Level: L2 (Walk)

NIST 800-53: AC-3, SC-7, AC-4

Description

Restrict access to production domains — not just preview URLs — to authenticated users, corporate IP ranges, or password-holders. Vercel Authentication on the All Deployments scope is included on every plan, Hobby included; Password Protection costs $20/month per protected project on Pro and is included on Enterprise; production-only Trusted IPs are Enterprise-only. Private production used to require the $150/month Advanced Deployment Protection add-on, which Vercel now lists as a legacy package (Vercel: Deployment Protection pricing).

Rationale

Why This Matters:

  • Internal tools, admin consoles, and staging-adjacent production workloads often have no business being indexed by search engines or reachable by anonymous traffic
  • “Private production” reduces attack surface for applications that only serve authenticated users anyway
  • Vercel Authentication on All Deployments and Deployment Protection Exceptions are included on every plan, so gating production no longer needs an add-on or an Enterprise contract (Vercel changelog)

Attack Prevented: Anonymous reconnaissance of production admin surfaces, credential-stuffing at public login pages, automated scanning of production endpoints.

Prerequisites

  • Vercel Authentication on All Deployments: any plan (Hobby, Pro, Enterprise), no additional charge
  • Password Protection: Pro ($20/month per protected project) or Enterprise (included); Pro teams on the legacy Advanced Deployment Protection package ($150/month per team) keep team-level Password Protection on that billing
  • Trusted IPs: Enterprise plan
  • Trusted IP list (if using Trusted IPs) or IdP for Vercel Authentication or password distribution channel

ClickOps Implementation

Step 1: Open Deployment Protection and Confirm Plan Coverage

  1. Navigate to: Project Settings → Deployment Protection
  2. Vercel Authentication with the All Deployments scope needs no purchase on any plan
  3. Password Protection on Pro adds a $20/month charge for this project when you enable it; disabling it stops future charges (Vercel: Password Protection pricing)
  4. Trusted IPs requires Enterprise

Step 2: Choose a Scope

  1. All Deployments: preview + production + generated URLs all require authentication
  2. Only Production Deployments (Trusted IPs, Enterprise): production domain restricted to trusted IPs; preview remains publicly accessible for iteration
  3. Standard Protection + Exceptions: keep standard protection, explicitly grant named exceptions for external services or shareable preview links

Step 3: Configure Method

  1. Select Vercel Authentication (team members only), Password Protection (strong password distributed via secrets manager), or Trusted IPs (Enterprise)
  2. For production-only Trusted IPs, set protection_mode = trusted_ip_required and deployment_type = production

Step 4: Review Scope and Cost Quarterly

  1. On Pro, Password Protection bills each protected project monthly; confirm every charged project still needs it
  2. Review the use-case quarterly to decide whether to keep private production, return to Standard Protection, or move to Enterprise for Trusted IPs

Time to Complete: ~20 minutes (plus billing approval if Password Protection is enabled on Pro)

Code Implementation

Code Pack: Terraform
hth-vercel-2.04-private-production-deployments.tf View source on GitHub ↗
# Applied through vercel_project.hardened (hth-vercel-2.01).
locals {
  private_production = var.profile_level >= 2 && var.private_production_deployments_enabled

  # L2: "all_deployments" also covers every production domain, including the
  # auto-assigned <project>.vercel.app (no extra charge with Vercel
  # Authentication); otherwise the current Standard Protection scope
  # ("standard_protection_new") applies, which leaves production domains public. The provider's "standard_protection" and "only_preview_deployments"
  # are the (Legacy) scopes the guide says to migrate away from.
  vercel_authentication_scope = local.private_production ? "all_deployments" : "standard_protection_new"
  password_protection_scope   = local.private_production ? "all_deployments" : "standard_protection_new"

  # L3: Trusted IPs on production domains only, previews stay reachable (Enterprise)
  trusted_ips_scope = var.production_only_trusted_ips_enabled ? "only_production_deployments" : "all_deployments"
}

Validation & Testing

  1. Unauthenticated request to production domain returns the Vercel auth/password gate
  2. Non-trusted-IP request to production is blocked at the edge (Enterprise Trusted IPs scope)
  3. Deployment Protection Exceptions work for the specific named paths/services only
  4. Billing shows a $20/month Password Protection charge for each protected Pro project and no charge for Vercel Authentication on All Deployments (teams on the legacy package see Team Level Password Protection instead)

Expected result: Production domains enforce the chosen protection method end-to-end.

Operational Impact

Aspect Impact Level Details
User Experience Medium-High End users outside the team/trusted IPs cannot reach production
System Performance None Enforced at the edge
Maintenance Burden Low Password rotation + Trusted IP list maintenance
Rollback Difficulty Low Switch the scope back to Standard Protection; disabling Password Protection stops future charges

Potential Issues:

  • Webhook callers (Slack, Stripe, external CI) will need Protection Bypass for Automation tokens or be added to Deployment Protection Exceptions
  • Public search engine indexing is suppressed — do not use Private Production for content that must remain discoverable

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.6 Access controls, physical and logical boundaries
NIST 800-53 AC-3, AC-4, SC-7 Access enforcement, information flow enforcement, boundary protection
ISO 27001 A.13.1.3 Segregation in networks
PCI DSS 1.3, 6.4.1 Prohibit direct public access, separate dev/test from production

2.5 Enable Protected Source Maps

Profile Level: L1 (Crawl)

NIST 800-53: AC-3, SC-28, CM-6

Description

Protected Source Maps restrict browser .map files so they are served only to authenticated viewers instead of to the anonymous internet. The setting is available on all plans and is enabled by default for newly created projects — but existing projects must opt in, which means any project created before the feature shipped is still publishing its source maps unless someone turned the toggle on.

Rationale

Why This Matters:

  • Public .map files reconstruct original, unminified source — including internal route names, feature flags, comments, API shapes, and occasionally hard-coded values that were never meant to leave the build
  • The default-on behaviour applies only to new projects, so the exposure is silent and skews toward your oldest, most business-critical applications
  • Unauthorized requests are answered with 404, not 401/403, so an attacker cannot use the response code to confirm that a given map path exists

Attack Prevented: Source code disclosure via published source maps, reconnaissance of internal application structure ahead of a targeted attack, discovery of secrets accidentally compiled into client bundles.

Prerequisites

  • Any Vercel plan (Hobby, Pro, Enterprise)
  • Project Admin or Team Owner access
  • An inventory of CI jobs and error trackers that fetch .map files from the deployment

ClickOps Implementation

Step 1: Audit Existing Projects

  1. For every project created before the feature shipped, request a known .map URL from the production deployment while logged out
  2. Any 200 response means the project is exposed and must be remediated in Step 2

Step 2: Enable the Toggle

  1. Navigate to: Project Settings → Deployment Protection → Protected Source Maps
  2. Toggle to Enabled
  3. Confirm new projects inherit the enabled default; do not assume the setting propagated to existing ones

Step 3: Understand the Scope Boundary

  1. Protection covers browser .map files served from the deployment only
  2. It does not cover inline source maps compiled into the bundle, source maps for server-side Vercel Functions, or maps you upload to a third-party error tracker — those remain governed by wherever they land
  3. If your build inlines source maps, remove the inlining before relying on this control

Step 4: Repair Legitimate Consumers

  1. CI pipelines that upload maps to an error tracker (Sentry, Datadog, Bugsnag) must send the Protection Bypass for Automation header (Section 2.1, Step 5) on the map fetch
  2. Human debugging of a protected deployment goes through Vercel Toolbar → Debug Mode rather than a raw fetch

Step 5: Verify via API (Optional)

  1. The same setting is exposed on the REST API project object as protectedSourcemaps; PATCH /v9/projects/{id} with that field set to true flips it programmatically for bulk remediation

Time to Complete: ~15 minutes (plus inventory time proportional to project count)

Code Implementation

Code Pack: Terraform
hth-vercel-2.05-enable-protected-source-maps.tf View source on GitHub ↗
# Applied through vercel_project.hardened (hth-vercel-2.01): .map requests
# must pass Deployment Protection; everyone else receives a 404.
locals {
  protected_sourcemaps = true
}

Validation & Testing

  1. An unauthenticated request for a known .map path returns 404
  2. An authenticated team member using Vercel Toolbar Debug Mode can still retrieve the map
  3. The error tracker continues to receive uploaded maps from CI (bypass header present)
  4. Every pre-existing project has been checked, not just the newest ones

Expected result: No deployment serves browser source maps to anonymous requests, and every legitimate consumer authenticates explicitly.

Monitoring & Maintenance

  • On project creation: Confirm the default remained enabled
  • Quarterly: Re-run the logged-out .map probe across production domains as part of the Section 1.5 audit
  • On event: Re-verify after any change to the build’s source-map configuration

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.7 Logical access controls, protection of confidential information
NIST 800-53 AC-3, CM-6, SC-28 Access enforcement, configuration settings, protection of information at rest
ISO 27001 A.9.4.1, A.14.2.5 Information access restriction, secure system engineering principles
PCI DSS 6.5, 1.3 Secure coding practices, prohibit direct public access

3. Web Application Firewall

3.1 Enable WAF with Managed Rulesets

Profile Level: L2 (Walk)

NIST 800-53: SC-7, SI-3

Description

Enable the Vercel Web Application Firewall with OWASP managed rulesets, bot protection, and AI bot filtering.

Rationale

Why This Matters:

  • Vercel WAF cannot be bypassed once enabled — all traffic passes through it
  • Managed rulesets protect against OWASP Top 10 without custom rule writing
  • Vercel paid $1M+ across 20 unique bypass techniques during the React2Shell bounty, validating rule tuning — but the result was specific to that vulnerability class and does not guarantee protection against future CVEs
  • Rules propagate globally in under 300ms with instant rollback capability
  • Vercel Firewall uses JA3/JA4 TLS client-hello fingerprints in addition to IP, header, and path signals to classify traffic

Attack Prevented: SQL injection, XSS, command injection, path traversal, remote file inclusion, bot abuse, AI scraping.

Real-World Incidents:

  • CVE-2025-29927 (Next.js middleware auth bypass): Vercel deployed a WAF rule stripping x-middleware-subrequest at the edge before public disclosure — Vercel-hosted customers were auto-protected. Discovered by zhero_web_security + yvvdwf.
  • React2Shell (CVE-2025-55182 / 66478): Critical RCE in React Server Components — Vercel shipped 20 WAF iterations in 48 hours during the $1M bounty, but external researchers consistently found bypasses, underscoring that patching the framework is mandatory (see Section 9).

Firewall Rule Execution Order

Per Vercel docs, every request passes through these layers in order:

  1. DDoS mitigation (automatic, all plans)
  2. WAF IP blocking
  3. WAF custom rules (including Persistent Actions — see Section 3.3)
  4. WAF Managed Rulesets

Reverse-Proxy Caveat

Placing a reverse proxy (Cloudflare, Azure Front Door, AWS CloudFront) in front of Vercel significantly degrades Bot Protection accuracy: the proxy masks JA3/JA4 signals and rotates exit IPs that Vercel relies on for classification. If a dedicated perimeter WAF is required for multi-cloud or regulatory reasons, disable Vercel Bot Protection and rely on the front WAF; otherwise run Vercel Firewall directly.

Prerequisites

  • Vercel Enterprise plan for managed rulesets
  • Pro plan for custom rules (up to 40)

ClickOps Implementation

Step 1: Enable Firewall

  1. Navigate to: Project → Firewall
  2. Toggle firewall to Enabled

Step 2: Enable OWASP Managed Rulesets (Enterprise)

  1. Navigate to: Firewall → Rules → WAF Managed Rulesets
  2. Enable OWASP Core Ruleset in Log mode first
  3. Monitor live traffic in the Firewall observability view for 48-72 hours and tune false positives
  4. Switch to Deny mode rule-by-rule after tuning

Step 3: Enable Bot Protection (Challenge)

  1. From Firewall → Rules → Bot Management, set the Bot Protection managed rule to Challenge
  2. Verified bots (Googlebot, webhook providers, services on the bots.fyi directory) are auto-allowed
  3. For custom automated clients, add a WAF Custom Rule with a Bypass action matching your User-Agent or Signature-Agent header

Step 4: Enable AI Bots Managed Ruleset

See Section 3.4 — configure in Log mode, review for 7 days, then decide Deny vs Allow based on your content licensing policy.

Step 5: Configure Custom Rules (Pro+)

  1. Create rules for application-specific protection; always start in Log mode
  2. For GitOps, declare rules in vercel.json under routes[].mitigate — but note only challenge and deny actions are supported in config-as-code; log, bypass, and redirect are dashboard-only
  3. Pair abuse-blocking rules with Persistent Actions (Section 3.3) to prevent repeat requests billing through the CDN

Time to Complete: ~30 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-3.01-enable-waf-managed-rulesets.tf View source on GitHub ↗
# ONE vercel_firewall_config per project, created only when this pack is asked
# to manage the firewall. The provider PUTs the WHOLE firewall config on create:
# every custom rule, IP block and managed-ruleset setting the project has now is
# replaced by what is declared here, including rules made in the dashboard and
# the hth-* rules the 3.3 and 9.1 API packs insert. With the defaults nothing is
# managed, so an apply leaves the project's firewall as it is. The 3.2 IP-block
# and rate-limit rules are merged in here from locals.
locals {
  firewall_managed = var.firewall_enabled || length(local.firewall_ip_rules) > 0 || length(local.firewall_rate_limit_rules) > 0
}

resource "vercel_firewall_config" "project" {
  count = local.firewall_managed ? 1 : 0

  project_id = var.project_id
  team_id    = var.vercel_team_id
  enabled    = true

  # L2: OWASP core ruleset (Enterprise), one action per rule group
  dynamic "managed_rulesets" {
    for_each = var.profile_level >= 2 ? [1] : []
    content {
      owasp {
        xss  = { action = var.waf_owasp_action, active = true }
        sqli = { action = var.waf_owasp_action, active = true }
        rce  = { action = var.waf_owasp_action, active = true }
        lfi  = { action = var.waf_owasp_action, active = true }
        rfi  = { action = var.waf_owasp_action, active = true }
        gen  = { action = var.waf_owasp_action, active = true }
      }
    }
  }

  # 3.2 (L2): rate-limit rules
  dynamic "rules" {
    for_each = length(local.firewall_rate_limit_rules) > 0 ? [1] : []
    content {
      dynamic "rule" {
        for_each = local.firewall_rate_limit_rules
        content {
          name            = rule.value.name
          active          = true
          action          = rule.value.action
          condition_group = rule.value.condition_group
        }
      }
    }
  }

  # 3.2 (L1): IP blocking
  dynamic "ip_rules" {
    for_each = length(local.firewall_ip_rules) > 0 ? [1] : []
    content {
      dynamic "rule" {
        for_each = local.firewall_ip_rules
        content {
          action   = rule.value.action
          hostname = rule.value.hostname
          ip       = rule.value.ip
          notes    = rule.value.notes
        }
      }
    }
  }

  lifecycle {
    precondition {
      condition     = length(var.blocked_ip_addresses) == 0 || var.firewall_hostname != ""
      error_message = "Set firewall_hostname: every IP-block rule needs the hostname it applies to."
    }
    precondition {
      condition     = var.firewall_replace_existing_config
      error_message = "This resource replaces the project's WHOLE firewall configuration, including custom rules and IP blocks added in the dashboard or by the 3.3/9.1 API packs. Export the active config first (GET /v1/security/firewall/config/active), declare every rule to keep, then set firewall_replace_existing_config = true."
    }
  }
}

Validation & Testing

  1. WAF is enabled and processing traffic (check Firewall tab)
  2. OWASP rules detecting common attack patterns in logs
  3. Bot protection challenging automated requests
  4. AI bots blocked (if configured)

Expected result: WAF actively filtering malicious traffic with managed rulesets

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 Security measures against threats outside boundaries
NIST 800-53 SC-7, SI-3 Boundary protection, malicious code protection
ISO 27001 A.13.1.1 Network controls
PCI DSS 6.6 Web application firewall

3.2 Configure IP Blocking and Rate Limiting

Profile Level: L1 (Crawl)

NIST 800-53: SC-5, SI-4

Description

Implement IP-based access control and rate limiting to protect against brute force attacks, abuse, and targeted threats.

Rationale

Why This Matters:

  • IP blocking is available on all plans, but the rule budget is small on Hobby — plan the block list against the limits below rather than assuming headroom
  • Rate limiting prevents brute force, credential stuffing, and API abuse
  • Persistent actions automatically block repeat offenders for configurable durations

Attack Prevented: Brute force attacks, credential stuffing, API abuse, scraping, DDoS amplification

IP Blocking Limits by Plan (Primary Source)

Per Vercel WAF docs:

Capability Hobby Pro Enterprise
Project-level IP blocking Up to 3 Up to 100 Up to 1000
Account-level IP blocking N/A N/A Custom
WAF Custom Rules 3 40 Up to 1000

CIDR ceiling: account-level IP blocking rules accept a maximum prefix size of /16 for IPv4 and /48 for IPv6 — a broader range must be expressed as multiple rules, which consumes the budget above.

JA3 (Legacy) is available as a Custom Rule Parameter on Enterprise only; JA4 is the current fingerprint and should be preferred for new rules.

ClickOps Implementation

Step 1: Block Known Bad IPs

  1. Navigate to: Project → Firewall → IP Blocking
  2. Add known malicious IP addresses or ranges — stay within the per-plan limits above
  3. Use per-host blocking for domain-specific rules

Step 2: Configure Rate Limiting Rules (Pro+)

  1. Navigate to: Firewall → Configure → Rules
  2. Create rate limiting rules for sensitive endpoints:
    • Authentication endpoints: 10 requests/minute per IP
    • API endpoints: appropriate limits per use case
    • Registration: 5 requests/minute per IP
  3. Set follow-up action to Deny with persistent duration (e.g., 5 minutes)
  4. Use Log action first to validate thresholds

Step 3: Enable Persistent Actions

  1. Configure persistent actions on deny/challenge rules
  2. Set duration based on attack type (1 min for rate limits, longer for abuse patterns)

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-3.02-ip-blocking-rate-limiting.tf View source on GitHub ↗
# Applied through vercel_firewall_config.project (hth-vercel-3.01).
locals {
  # L1: block known-bad IPs/ranges (ip_rules.rule requires a hostname)
  firewall_ip_rules = [for ip in var.blocked_ip_addresses : {
    action   = "deny"
    hostname = var.firewall_hostname
    ip       = ip.value
    notes    = ip.note != "" ? ip.note : "Block ${ip.value}"
  }]

  # L2: rate-limit sensitive paths (algo and keys are required)
  firewall_rate_limit_rules = var.profile_level >= 2 ? [for r in var.rate_limit_rules : {
    name = r.name
    action = {
      action = "rate_limit"
      rate_limit = {
        algo   = "fixed_window"
        keys   = ["ip"]
        limit  = r.limit
        window = r.window
        action = r.follow_up_action
      }
    }
    condition_group = [{ conditions = [{ type = "path", op = "pre", value = r.path }] }]
  }] : []
}

Validation & Testing

  1. Blocked IPs return 403/challenge response
  2. Rate-limited endpoints enforce configured thresholds
  3. Persistent actions block repeat offenders
  4. Rules show in Firewall activity logs

Expected result: Malicious and abusive traffic blocked at the edge

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 Security measures against external threats
NIST 800-53 SC-5, SI-4 Denial of service protection, system monitoring
ISO 27001 A.13.1.2 Security of network services
PCI DSS 11.4 Intrusion-detection/prevention techniques

3.3 Configure Firewall Persistent Actions

Profile Level: L2 (Walk)

NIST 800-53: SC-5, SI-4

Description

Persistent Actions are time-based IP-level blocks that execute before the request reaches the CDN. On first match, subsequent requests from the same source are rejected at the firewall edge for the configured duration without accruing CDN bandwidth or compute billing.

Rationale

Why This Matters:

  • Repeat-abuse scans (vulnerability probes, brute-force, scraping) drive the largest share of attacker-induced cost amplification on Vercel — the Shared Responsibility Model explicitly makes malicious-traffic billing the customer’s responsibility
  • Without Persistent Actions, every retry from the same IP incurs at least minimal CDN processing cost
  • With Persistent Actions, the first match creates a time-boxed block that enforces at the edge for free

Attack Prevented: Attacker-driven cost amplification, scanner persistence, brute-force credential attacks.

Prerequisites

  • WAF Custom Rules enabled on the project (Pro+)
  • Known abuse patterns or sensitive endpoints to protect

ClickOps Implementation

Step 1: Identify Targets

  1. Review Firewall observability for the top 10 probed paths (typical: /.env, /.git, /wp-admin, /admin, /phpmyadmin)
  2. Identify sensitive endpoints that must not be scanned (/api/auth/*, /api/billing/*)

Step 2: Create Persistent Deny Rule for Scanner Paths

  1. Navigate to: Firewall → Rules → Custom Rules → Create Rule
  2. Name: hth-persistent-block-scanners
  3. Condition: path starts with any of /.env, /.git, /wp-admin
  4. Action (Then): Deny, and set the timeframe dropdown (for) to 24 hours. That timeframe is the persistent action (API field actionDuration); removing it disables persistence

Step 3: Create Persistent Rate Limit on Auth Endpoints

  1. Create rule named hth-auth-rate-limit-persistent
  2. Condition: path starts with /api/auth
  3. Action: Rate Limit (20 req/min, fixed-window, keyed by IP) with follow-up action Deny and the for timeframe set to 1 hour

Step 4: Review Weekly

  1. From Firewall observability, verify Persistent Actions are firing against expected traffic
  2. Adjust thresholds or add exceptions for false positives

Time to Complete: ~20 minutes

Code Implementation

Code Pack: API Script
hth-vercel-3.03-firewall-persistent-actions.sh View source on GitHub ↗
# WARNING: if the Terraform module manages this project's firewall
# (vercel_firewall_config, 3.1/3.2), its next apply REPLACES the whole config
# and removes these rules. Manage custom rules in one place, not both.

FW_URL="https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}"

# curl -f aborts on HTTP 4xx/5xx; a 2xx body carrying .error also fails the run.
fw_patch() {
  local resp
  resp="$(curl -fsS -X PATCH \
    -H "Authorization: Bearer ${VERCEL_TOKEN}" \
    -H "Content-Type: application/json" \
    "${FW_URL}" -d @-)"
  if echo "${resp}" | jq -e 'type == "object" and has("error")' >/dev/null; then
    echo "${resp}" | jq '.error' >&2
    return 1
  fi
  echo "OK"
}

# A re-run must not duplicate rules (each duplicate also spends the plan's
# custom-rule quota), so a rule whose name already exists in the ACTIVE config is
# updated in place with "rules.update" instead of inserted again.
fw_upsert() {
  local body name id
  body="$(cat)"
  name="$(printf '%s' "${body}" | jq -r '.value.name')"
  id="$(printf '%s' "${ACTIVE_JSON}" | jq -r --arg n "${name}" '[.rules[]? | select(.name == $n) | .id][0] // empty')"
  if [ -n "${id}" ]; then
    echo "(rule ${name} already exists as ${id}: updating it in place)"
    printf '%s' "${body}" | jq -c --arg id "${id}" '{action: "rules.update", id: $id, value: .value}' | fw_patch
  else
    printf '%s' "${body}" | fw_patch
  fi
}

# --- Read current (active) firewall configuration ---
echo "=== Current Firewall Configuration ==="
ACTIVE_JSON="$(curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" \
  "https://api.vercel.com/v1/security/firewall/config/active?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}")"
printf '%s' "${ACTIVE_JSON}" | jq '{
    firewallEnabled,
    ruleCount: (.rules // [] | length),
    managedRules: (.managedRules // {} | keys),
    ipBlockCount: (.ips // [] | length)
  }'

# --- Persistent deny: block sources probing scanner paths for 24h on first
#     match (pre-CDN, zero billing cost) ---
echo ""
echo "=== Deploying Persistent-Action Block Rule ==="
fw_upsert <<'JSON'
{
  "action": "rules.insert",
  "id": null,
  "value": {
    "name": "hth-persistent-block-scanners",
    "description": "Block scanner IPs for 24h on hit to common probe paths",
    "active": true,
    "conditionGroup": [
      { "conditions": [ { "type": "path", "op": "pre", "value": "/.env" } ] },
      { "conditions": [ { "type": "path", "op": "pre", "value": "/.git" } ] },
      { "conditions": [ { "type": "path", "op": "pre", "value": "/wp-admin" } ] }
    ],
    "action": {
      "mitigate": {
        "action": "deny",
        "actionDuration": "24h"
      }
    }
  }
}
JSON

# --- Rate-limit authentication endpoints with a persistent follow-up ban ---
echo ""
echo "=== Deploying Auth Rate Limit with Persistent Ban ==="
fw_upsert <<'JSON'
{
  "action": "rules.insert",
  "id": null,
  "value": {
    "name": "hth-auth-rate-limit-persistent",
    "description": "Rate limit /api/auth/* and ban for 1h on violation",
    "active": true,
    "conditionGroup": [
      { "conditions": [ { "type": "path", "op": "pre", "value": "/api/auth" } ] }
    ],
    "action": {
      "mitigate": {
        "action": "rate_limit",
        "rateLimit": {
          "algo": "fixed_window",
          "window": 60,
          "limit": 20,
          "keys": ["ip"],
          "action": "deny"
        },
        "actionDuration": "1h"
      }
    }
  }
}
JSON

Validation & Testing

  1. Repeat probing from a single IP is blocked after the first hit for the configured duration
  2. The rules show a persistent timeframe (actionDuration) in the firewall configuration, and Firewall observability shows follow-up requests from a matched source being blocked
  3. Blocked requests do not appear in CDN bandwidth/compute usage

Expected result: Scanner and brute-force traffic is blocked at zero cost to the customer.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 External threat protections
NIST 800-53 SC-5(1), SI-4(4) Denial-of-service protection, inbound/outbound monitoring
ISO 27001 A.13.1.1 Network controls
PCI DSS 11.4 Network intrusion-detection/prevention

3.4 Configure AI Bots Managed Ruleset

Profile Level: L2 (Walk)

NIST 800-53: SI-4, AC-4

Description

Control traffic from known AI crawlers — training crawlers, search-assistant user fetches, and generative scrapers — using Vercel’s managed AI Bots ruleset. Log first, then decide whether to allow or deny based on your content-licensing and data-sensitivity posture.

Rationale

Why This Matters:

  • AI crawlers account for a growing share of request volume on public sites and can drive both cost and content-exfiltration concerns
  • Many AI crawlers ignore robots.txt; edge-side blocking is the only reliable enforcement
  • The AI Bots list is continuously updated by Vercel — new crawlers inherit your existing Log/Deny decision

Attack Prevented: Unlicensed training data extraction, competitive scraping, elevated costs from unwanted automated traffic.

Prerequisites

  • WAF Managed Rulesets available on plan (Enterprise, or Pro with applicable add-on)
  • Policy decision: allow, log, or deny AI crawlers

ClickOps Implementation

Step 1: Enable in Log Mode

  1. Navigate to: Firewall → Rules → Bot Management → AI Bots Ruleset
  2. Set action to Log
  3. Save and publish

Step 2: Observe for 7 Days

  1. Review Firewall observability daily and confirm no business-critical AI-assistant traffic (e.g., user-authorized ChatGPT web-browsing fetches for your internal users) is being matched

Step 3: Decide Deny vs Allow

  1. If content is proprietary or not licensed for AI training, switch to Deny
  2. If the site benefits from AI discoverability (docs, marketing), leave at Log and optionally add a narrower Custom Rule to block only specific crawlers that ignore robots.txt

Step 4: Document Exception Paths

  1. Use WAF Custom Rules with Bypass action to explicitly allow specific crawlers you do want (e.g., your own enterprise AI assistant)

Time to Complete: ~15 minutes (plus 7 days of observation)

Code Implementation

Code Pack: API Script
hth-vercel-3.04-ai-bots-managed-ruleset.sh View source on GitHub ↗
FW_URL="https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}"

# curl -f aborts on HTTP 4xx/5xx; a 2xx body carrying .error also fails the run.
fw_patch() {
  local resp
  resp="$(curl -fsS -X PATCH \
    -H "Authorization: Bearer ${VERCEL_TOKEN}" \
    -H "Content-Type: application/json" \
    "${FW_URL}" -d "$1")"
  if echo "${resp}" | jq -e 'type == "object" and has("error")' >/dev/null; then
    echo "${resp}" | jq '.error' >&2
    return 1
  fi
  echo "OK"
}

# --- Currently active managed rulesets (the config field is managedRules) ---
echo "=== Active Managed Rulesets ==="
curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" \
  "https://api.vercel.com/v1/security/firewall/config/active?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" | \
  jq '.managedRules // {}'

# --- AI Bots Managed Ruleset: LOG first (observe before denying) ---
echo ""
echo "=== Setting AI Bots Managed Ruleset to ${AI_BOTS_ACTION} ==="
fw_patch "$(jq -n --arg a "${AI_BOTS_ACTION}" \
  '{action: "managedRules.update", id: "ai_bots", value: {active: true, action: $a}}')"

# --- Bot Protection Managed Ruleset: CHALLENGE ---
echo ""
echo "=== Enabling Bot Protection Managed Ruleset (challenge mode) ==="
fw_patch '{"action":"managedRules.update","id":"bot_protection","value":{"active":true,"action":"challenge"}}'

Validation & Testing

  1. Known AI crawler user-agents hit the rule when probing the site
  2. Firewall observability shows AI Bot traffic volume before/after the action change
  3. Legitimate bots (search engines, webhook providers) remain unaffected

Expected result: AI crawler traffic is visible and (optionally) blocked.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 External threat and unauthorized-access protection
NIST 800-53 SI-4, AC-4 Monitoring, information flow enforcement
ISO 27001 A.13.1.1, A.13.2.1 Network controls; information transfer policies
PCI DSS 12.10.5 Include alerts from monitoring systems

3.5 Protect High-Value Routes with Vercel BotID

Profile Level: L2 (Walk)

NIST 800-53: SC-5, SI-4, IA-2

Description

Vercel BotID is an invisible CAPTCHA that protects specific high-value routes — checkout, signup, login, expensive AI or search APIs — against sophisticated bots that execute JavaScript and defeat signature-based classification (Playwright, Puppeteer, and their stealth forks). It is a distinct product from Bot Protection (Section 3.1) and the AI Bots Managed Ruleset (Section 3.4): those classify site-wide traffic, BotID gates individual handlers. Unlike every other firewall control in this guide, BotID is implemented in application code, not in the dashboard.

Rationale

Why This Matters:

  • Managed bot rulesets classify on request-level signals (user agent, JA4, IP reputation); a headless browser driving a real JS runtime looks like a browser and passes them
  • The cost of bot abuse concentrates on a handful of endpoints — an inference API or a signup form — so per-route protection buys most of the benefit for a fraction of the friction
  • Basic BotID is free on all plans including Hobby, which makes “we couldn’t afford bot defense” no longer a valid reason to leave a checkout endpoint open

Attack Prevented: Automated account creation and credential stuffing, checkout/inventory abuse and scalping, cost-amplification against metered APIs, scripted scraping of authenticated surfaces.

Protection Levels

Level What it does Availability
Basic Challenge-integrity validation on the declared routes Free on all plans, including Hobby
Deep Analysis Kasada-powered ML classification, executed only after Basic passes Pro at $1 per 1,000 checkBotId() calls; Enterprise custom

Prerequisites

  • A project deployed on Vercel (BotID is enforced at the Vercel edge)
  • Next.js 15.3+ for the instrumentation-client.ts integration path, or a supported alternative (Nuxt module; vercel.json rewrites for other frameworks)
  • A decided list of protected (path, method) pairs — BotID is opt-in per route, not global

ClickOps Implementation

Step 1: Enable Deep Analysis (Dashboard)

  1. Navigate to: Dashboard → Project → Firewall → Rules → Vercel BotID Deep Analysis
  2. Enable it if you want ML classification on top of the free Basic tier
  3. Note the Pro metering: $1 per 1,000 checkBotId() calls — scope the protected route list accordingly

Step 2: Install and Wire the Package (Code)

  1. Install the botid package into the application
  2. Wrap the framework config — for Next.js, wrap next.config with withBotId(); Nuxt uses the dedicated module; other frameworks proxy the BotID endpoints via vercel.json rewrites
  3. This step is what routes the challenge traffic; skipping it silently disables enforcement

Step 3: Declare Protected Routes Client-Side

  1. Call initBotId() with a protect array of (path, method) pairs in instrumentation-client.ts (Next.js 15.3+), or render the <BotIdClient /> component with the same declarations
  2. Gotcha: a route that is checked server-side but never declared client-side will make checkBotId() fail — the client declaration is what arms the challenge

Step 4: Gate the Handler Server-Side

  1. At the top of each protected handler, call checkBotId() and return 403 when the result reports isBot
  2. Treat this as authorization code: it belongs in the handler, not in middleware (Section 10.2)
  3. Gotcha: in local development checkBotId() always reports isBot: false unless developmentOptions is configured — never conclude from a passing local test that enforcement works

Step 5: Observe and Tune

  1. Use the Firewall tab traffic dropdown and filter on BotID to see classification volume per route
  2. To exempt a known-good automated client, add a WAF Custom Rule with a Bypass action (Section 3.1) rather than removing the route from the protected list

Time to Complete: ~45 minutes (dashboard toggle plus application changes)

Code Implementation

Code Pack: SDK Script
hth-vercel-3.05-protect-routes-botid.js View source on GitHub ↗
// next.config.js — route the BotID challenge traffic through the app's own
// origin so ad-blockers and third-party scripts cannot strip the challenge.
// Skipping this wrapper silently disables enforcement.
import { withBotId } from 'botid/next/config';

const nextConfig = {
  // Your existing Next.js config
};

export default withBotId(nextConfig);
// instrumentation-client.js (Next.js 15.3+) — declare every protected
// (path, method) pair client-side. A route checked server-side but never
// declared here will make checkBotId() fail: this declaration is what
// attaches the classification headers that arm the challenge.
import { initBotId } from 'botid/client/core';

initBotId({
  protect: [
    {
      // High-value API endpoint: checkout
      path: '/api/checkout',
      method: 'POST',
    },
    {
      // Wildcards can expand multiple segments:
      // /team/*/activate matches /team/a/activate, /team/a/b/activate, ...
      path: '/team/*/activate',
      method: 'POST',
    },
    {
      // Trailing wildcard for dynamic routes
      path: '/api/user/*',
      method: 'POST',
    },
  ],
});
// app/api/checkout/route.js — gate the handler server-side. This is
// authorization code: it belongs in the handler, not in middleware.
// Local development always returns isBot: false unless developmentOptions
// is configured on checkBotId() — verify against a deployed environment.
import { checkBotId } from 'botid/server';
import { NextResponse } from 'next/server';

export async function POST(request) {
  // Check if the request is from a bot
  const verification = await checkBotId();

  if (verification.isBot) {
    return NextResponse.json(
      { error: 'Bot detected. Access denied.' },
      { status: 403 },
    );
  }

  // Process the legitimate checkout request
  const body = await request.json();

  // Your checkout logic here
  const order = await processCheckout(body);

  return NextResponse.json({
    success: true,
    orderId: order.id,
  });
}

async function processCheckout(data) {
  // Implement your checkout logic
  return { id: 'order-123' };
}

Validation & Testing

  1. A headless-browser request to a protected route receives 403; a normal browser request succeeds
  2. Every route passed to checkBotId() server-side also appears in the client-side protect declarations
  3. Deep Analysis shows classification events in the Firewall tab’s BotID filter (if enabled)
  4. Verification was performed against a deployed environment, not local dev
  5. Bypass rules exist for each legitimate automated consumer and are reviewed quarterly

Expected result: High-value routes reject JS-capable bots at the edge while ordinary users see nothing.

Operational Impact

Aspect Impact Level Details
User Experience None Invisible challenge — no interaction required
System Performance Low Challenge runs client-side; check adds a single edge call per protected request
Maintenance Burden Medium Route list must be kept in sync between client declaration and server check
Rollback Difficulty Easy Remove the checkBotId() gate or disable Deep Analysis in the dashboard

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6, CC6.1 External threat protection, logical access controls
NIST 800-53 SC-5, SI-4, IA-2 Denial-of-service protection, system monitoring, identification and authentication
ISO 27001 A.13.1.1, A.9.4.1 Network controls, information access restriction
PCI DSS 6.6, 8.1 Web application firewall, unique identification

4. Network Security

4.1 Enable Secure Compute

Profile Level: L3 (Run)

NIST 800-53: SC-7, SC-8

Description

Deploy Serverless Functions within dedicated private networks with static IPs, VPC peering, and network isolation using Vercel Secure Compute.

Rationale

Why This Matters:

  • Dedicated IP pairs not shared with any other customer
  • Enables IP allowlisting on backend databases and APIs
  • Full network isolation in a private VPC
  • Regional failover with active + passive networks

Attack Prevented: Shared IP abuse, unauthorized backend access, network-level lateral movement.

Critical Architecture Caveats (Primary Source)

Per Vercel Secure Compute docs:

  • Edge Runtime is NOT supported. Routing Middleware and edge functions do not route through Secure Compute and will use shared Vercel IP pools. If your backend allowlists only Secure Compute static IPs, middleware traffic will be rejected by the backend — or worse, silently fall back to a shared-IP path you thought was blocked. Design middleware to not require access to backend services allowlisted on Secure Compute, or rewrite middleware logic into Node.js / Python / Ruby runtimes that do route through Secure Compute.
  • Supported runtimes: Node.js, Python, Ruby only.
  • VPC peering limit: maximum 50 peering connections per Secure Compute network.
  • Build container inclusion is optional. Include only if builds access allowlisted data sources; otherwise opt out to save the ~5-second provision delay.
  • Active + Passive networks provide regional failover per project environment; both must be provisioned explicitly.

Prerequisites

  • Vercel Enterprise plan
  • Secure Compute add-on ($6,500/year + $0.15/GB Secure Connect Data Transfer)
  • Backend services supporting IP allowlisting
  • Application audit: confirm middleware / edge functions do not require backend services that will be allowlist-restricted to Secure Compute IPs

ClickOps Implementation

Step 1: Audit Application for Edge-Runtime Dependencies

  1. List all middleware files and edge-runtime functions in the project
  2. Trace each outbound HTTP/DB call from those surfaces and confirm it does not target a service that will be IP-allowlisted to Secure Compute
  3. Move any such calls into Node.js/Python/Ruby function runtimes before enabling backend IP allowlisting

Step 2: Create Secure Compute Network

  1. Navigate to: Team Settings → Networking, then select Create Network
  2. Select AWS region closest to your backend
  3. Configure CIDR block (must not overlap with VPC peer ranges)
  4. Select availability zones

Step 3: Assign Projects

  1. In each project, navigate to: Project Settings → Networking
  2. Set the Active Network per environment (Production, Preview, etc.), and optionally a Passive Network for failover
  3. Optionally include build container (adds ~5s provisioning delay)

Step 4: Configure VPC Peering (Optional, max 50 per network)

  1. Create peering connection from Vercel dashboard
  2. Accept in AWS VPC dashboard
  3. Update route tables in both VPCs
  4. Configure security groups to allow Vercel IP ranges

Step 5: Update Backend Allowlists

  1. Add Vercel dedicated IPs to backend database firewall rules
  2. Add to API gateway IP allowlists
  3. Always layer authentication on top of IP filtering — IP alone is not sufficient

Step 6: Configure Region Failover

  1. Create Active + Passive networks in different regions
  2. Link both to each project environment
  3. Vercel automatically switches to the Passive network if the primary region fails

Time to Complete: ~90 minutes (including application audit)

Code Implementation

Code Pack: Terraform
hth-vercel-4.01-enable-secure-compute.tf View source on GitHub ↗
# --- L3: Create a Secure Compute network (Enterprise) ---
resource "vercel_network" "secure_compute" {
  count = var.profile_level >= 3 && var.secure_compute_enabled ? 1 : 0

  team_id = var.vercel_team_id
  name    = var.secure_compute_name
  region  = var.secure_compute_region
  cidr    = var.secure_compute_cidr
}

# --- Connecting a project to the network has no Terraform resource in the
#     vercel/vercel provider: do it in the project's Settings -> Networking. ---

Validation & Testing

  1. Functions connect to backend via private network
  2. Backend rejects connections from non-Vercel IPs
  3. Region failover switches to passive network on outage
  4. VPC peering routes traffic correctly (if configured)

Expected result: Serverless Functions operate in isolated private network with static egress IPs

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 SC-7, SC-8 Boundary protection, transmission confidentiality
ISO 27001 A.13.1.3 Segregation in networks
PCI DSS 1.3 Network access to the cardholder data environment is restricted

4.2 Configure DDoS Protection and Attack Challenge Mode

Profile Level: L1 (Crawl)

NIST 800-53: SC-5, CP-10

Description

Leverage Vercel’s automatic DDoS mitigation and configure Attack Challenge Mode for active attack response.

Rationale

Why This Matters:

  • Automatic L3/L4/L7 DDoS mitigation on all plans at no cost
  • Blocked DDoS traffic is NOT billed
  • Attack Challenge Mode provides additional layer during active targeted attacks
  • System Bypass Rules prevent legitimate traffic from being blocked

Attack Prevented: Volumetric DDoS, SYN floods, application-layer floods, amplification attacks

Attack Challenge Mode — Internal Request Boundary (Primary Source)

Per Vercel docs:

  • When Attack Challenge Mode is enabled, requests from your own Vercel account’s Functions, Cron Jobs, and projects are automatically allowed through without being challenged. Other Vercel accounts cannot bypass your ACM.
  • Known verified bots (search engines, webhook providers, services listed in the Vercel bot directory) are auto-allowed.
  • All traffic initiated by web browsers is supported, including SPA API traffic between pages of the same Vercel project.
  • Standalone APIs and non-browser clients may fail the JavaScript challenge and be blocked. If your site serves machine-to-machine APIs from the same deployment, plan bypass paths (WAF Custom Rule with Bypass action) before enabling.
  • ACM is free on all plans, unlimited, and blocked requests do not count toward usage quotas.
  • Safe for extended enablement — Googlebot and other verified crawlers remain unaffected, so SEO is not harmed.

ClickOps Implementation

Step 1: Verify DDoS Protection (Automatic)

  1. DDoS protection is always enabled — no configuration required
  2. Verify in: Project → Firewall — should show traffic monitoring

Step 2: Configure Attack Challenge Mode (During Attacks)

  1. Navigate to: Project → Firewall → Bot Management → Attack Challenge Mode
  2. Enable during active attacks — challenges browser traffic with a JS challenge
  3. Same-account Vercel requests (your Functions, Cron Jobs, cross-project calls) auto-bypass
  4. Verified bots auto-bypass
  5. Disable when attack subsides; Vercel recommends ACM as a targeted-attack tool, not a permanent setting
  6. For standalone APIs that will be called by non-browser clients, create a WAF Custom Rule with a Bypass action matching an API signature (e.g., User-Agent or x-api-key) before enabling ACM

Step 3: Configure Spend Management (Pro+)

  1. Navigate to: Team Settings → Billing → Spend Management
  2. Set usage thresholds with automatic actions
  3. Configure webhook notifications for usage spikes
  4. Enable auto-pause for non-critical projects — per the Shared Responsibility Model, malicious-traffic costs are customer-owned

Step 4: Configure System Bypass Rules (L2 — Pro+)

  1. Create rules to ensure essential traffic (trusted proxies, known partner IP ranges) is never blocked
  2. Use for business-critical external services

Time to Complete: ~10 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-4.02-ddos-attack-challenge-mode.tf View source on GitHub ↗
# --- L1: Attack Challenge Mode (activate during active attacks) ---
# Managed only while enabled. With the default (false) nothing is sent, so an
# apply never switches off Attack Challenge Mode that someone turned on during
# an attack. Setting the variable back to false destroys the resource, and the
# provider then switches the mode off.
resource "vercel_attack_challenge_mode" "protection" {
  count = var.attack_challenge_mode_enabled ? 1 : 0

  project_id = var.project_id
  team_id    = var.vercel_team_id
  enabled    = true

  # Required: Unix time in ms. Vercel turns the mode off when it passes, and
  # `enabled` then drifts to false.
  attack_mode_active_until = var.attack_mode_active_until

  lifecycle {
    precondition {
      condition     = var.attack_mode_active_until > 0
      error_message = "Set attack_mode_active_until (Unix ms) when enabling Attack Challenge Mode."
    }
  }
}

Validation & Testing

  1. DDoS mitigation active (always on – verify via Firewall dashboard)
  2. Attack Challenge Mode can be enabled/disabled
  3. Spend management alerts configured
  4. Blocked traffic not appearing in billing

Expected result: Multi-layered DDoS protection with cost controls

Compliance Mappings

Framework Control ID Control Description
SOC 2 A1.2 Environmental protections
NIST 800-53 SC-5, CP-10 Denial of service protection, system recovery
ISO 27001 A.17.2.1 Availability of information processing facilities
PCI DSS 11.4 Intrusion detection/prevention

5. Security Headers

5.1 Configure Security Response Headers

Profile Level: L1 (Crawl)

NIST 800-53: SI-10, SC-28

Description

Configure security headers (CSP, X-Frame-Options, Referrer-Policy, etc.) to protect against client-side attacks. Vercel does NOT set these automatically beyond HSTS – you must configure them.

Rationale

Why This Matters:

  • Vercel auto-configures HSTS but NO other security headers
  • Missing CSP enables XSS attacks; missing X-Frame-Options enables clickjacking
  • Security headers are the primary defense against client-side attacks
  • Headers must be set by the customer per Vercel’s shared responsibility model

Attack Prevented: Cross-site scripting (XSS), clickjacking, MIME-type sniffing, referrer leakage, unauthorized API embedding

Real-World Incidents:

  • Vercel XSS in Clone URL (2024): Reflected XSS found in Vercel’s own clone functionality – reinforces need for CSP even on trusted platforms

ClickOps Implementation

Step 1: Configure via vercel.json

  1. Add a headers configuration block to your vercel.json
  2. Apply to all routes using source: "/(.*)" pattern

Step 2: Required Security Headers

  1. Content-Security-Policy: Define allowed content sources (most impactful header)
  2. X-Frame-Options: Set to DENY or SAMEORIGIN
  3. X-Content-Type-Options: Set to nosniff
  4. Referrer-Policy: Set to strict-origin-when-cross-origin
  5. Permissions-Policy: Restrict browser features (camera, microphone, geolocation, etc.)
  6. X-XSS-Protection: Set to 0 to switch the legacy XSS auditor off. Never 1; mode=block: the auditor can itself introduce XSS in otherwise safe pages, and the CSP in item 1 is the real protection (OWASP HTTP Headers Cheat Sheet)

Step 3: Validate

  1. Test with SecurityHeaders.com
  2. Review CSP reports if using report-uri or report-to directive

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Config
hth-vercel-5.01-security-response-headers.sh View source on GitHub ↗
# Any failed step (temp file, jq, curl) exits 2, never 1: 1 means "finding".
trap 'echo "ERROR: the header check stopped before a verdict; nothing was checked (exit 2)." >&2; exit 2' ERR

# --- Emit the vercel.json headers block to a private temp file ---
HEADERS_FILE="${HTH_HEADERS_OUT:-$(mktemp "${TMPDIR:-/tmp}/hth-vercel-headers.XXXXXX")}"
cat > "${HEADERS_FILE}" << 'HEADERS_EOF'
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
        },
        {
          "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=(), interest-cohort=()"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-XSS-Protection",
          "value": "0"
        }
      ]
    }
  ]
}
HEADERS_EOF

jq -e . "${HEADERS_FILE}" >/dev/null
echo "Security headers config written to ${HEADERS_FILE}"
echo "Merge it into your project's vercel.json, then deploy."

# --- Validate deployed headers: every header above must be present ---
DOMAIN="${1:-}"
if [ -z "${DOMAIN}" ]; then
  echo "Usage: $0 <your-domain.com>   (to check a deployed domain)"
  exit 0
fi

echo ""
echo "=== Validating Security Headers for ${DOMAIN} ==="
RESPONSE_HEADERS="$(curl -fsSI "https://${DOMAIN}" | tr -d '\r')"
PROBLEMS=0
CHECKED=0
for header in $(jq -r '.headers[0].headers[].key' "${HEADERS_FILE}"); do
  CHECKED=$((CHECKED + 1))
  if printf '%s\n' "${RESPONSE_HEADERS}" | grep -qi "^${header}:"; then
    echo "  present: ${header}"
  else
    echo "  MISSING: ${header}"
    PROBLEMS=1
  fi
done
# A failed jq inside the for-list is invisible to set -e: zero names read must
# not pass as "all present".
if [ "${CHECKED}" -eq 0 ]; then
  echo "ERROR: no header names read from ${HEADERS_FILE}; nothing was checked (exit 2)." >&2
  exit 2
fi

# --- X-XSS-Protection must switch the legacy XSS auditor OFF: "1; mode=block"
#     can itself introduce XSS in otherwise safe pages (OWASP HTTP Headers
#     Cheat Sheet); the CSP above is the protection. ---
XXSS="$(printf '%s\n' "${RESPONSE_HEADERS}" | grep -i '^x-xss-protection:' | head -1 | cut -d: -f2- | sed 's/^ *//; s/ *$//')" || true
if [ -n "${XXSS}" ] && [ "${XXSS}" != "0" ]; then
  echo "  WRONG VALUE: X-XSS-Protection is '${XXSS}' -- set it to 0"
  PROBLEMS=1
fi
exit "${PROBLEMS}"

Validation & Testing

  1. All six security headers present in response
  2. SecurityHeaders.com score of A or A+
  3. No CSP violations in browser console for legitimate resources
  4. X-Frame-Options prevents iframe embedding

Expected result: All security headers configured and validated

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 Security measures against threats
NIST 800-53 SI-10, SC-28 Information input validation, protection of information at rest
ISO 27001 A.14.1.2 Securing application services on public networks
PCI DSS 6.5.7 Cross-site scripting (XSS)

6. Secrets Management

6.1 Environment Variable Security

Profile Level: L1 (Crawl)

NIST 800-53: SC-28, SC-12

Description

Implement secure environment variable management with proper scoping, mandatory Sensitive flag, and access controls. Post April 2026 incident, the team-wide Enforce Sensitive Environment Variables policy is a baseline L1 control — not L2.

Rationale

Why This Matters:

  • All environment variables are encrypted at rest (AES-256) by Vercel, but non-sensitive variables can be decrypted and displayed by anyone with team-scope access via the dashboard or API. Only Sensitive variables are stored in a truly unreadable format.
  • Variables scoped to production are only accessible to Owner/Member/Project Admin roles
  • NEXT_PUBLIC_ prefixed variables are inlined into the client JavaScript bundle by Next.js — never use for secrets (see Section 6.4 for automated lint)
  • Preview branches can access production secrets if not properly scoped
  • Sensitive variables are not supported in the Development environment — local dev secrets must be managed out of band (1Password, HashiCorp Vault, Doppler)
  • Total limit: 64 KB per deployment; Edge Functions: 5 KB per variable

Build-log redaction has a length floor. Per Vercel’s sensitive environment variables docs, sensitive values are redacted from build logs only when they are 32 characters or longer — a short secret (a 16-character API key, a 6-digit PIN, a legacy shared password) still prints in plaintext into build output even when correctly flagged Sensitive. Two platform-managed values, VERCEL_AUTOMATION_BYPASS_SECRET and VERCEL_OIDC_TOKEN, are always redacted regardless of length. Generate every secret at 32+ characters so redaction actually engages, and treat any shorter legacy secret as build-log-exposed until rotated. Each redaction also emits an Activity Log event naming the key, project, and deployment (never the value) — a usable detection signal, see Section 8.2.

Attack Prevented: Secret exposure in client bundles, credential leakage via preview deployments, unauthorized production secret access, mass-exposure during platform incidents affecting non-sensitive storage, plaintext short-secret leakage into build logs.

Real-World Incidents:

  • Vercel April 2026 incident: The attacker enumerated and decrypted only non-sensitive customer environment variables. Variables explicitly marked Sensitive remained unreadable. Customers with the team-wide Sensitive Environment Variable policy enabled were protected. (Vercel KB Bulletin)

Attack Scenario: Developer creates a NEXT_PUBLIC_API_SECRET variable, exposing it in the client-side JavaScript bundle. Attacker views page source to extract the API key. See Cremit research — live API keys found in 0.45% of public Vercel deployments via this vector.

ClickOps Implementation

Step 1: Enforce Sensitive Environment Variable Policy (L1 — post April 2026)

  1. Navigate to: Team Settings → Security & Privacy → Environment Variable Policies
  2. Toggle Enforce Sensitive Environment Variables to Enabled (requires Owner role)
  3. All newly-created Production and Preview environment variables will now default to Sensitive and cannot be read back

Step 2: Retrofit Existing Variables

  1. Navigate to: Project Settings → Environment Variables
  2. For any variable holding a secret that is not flagged Sensitive: delete and recreate it with the Sensitive option enabled. (You cannot mark an existing variable Sensitive in place — you must remove and re-add.)
  3. Rotate the underlying secret value at the source system during this process (post-incident hygiene)

Step 3: Audit for Client-Bundle Leakage

  1. Verify no secrets use NEXT_PUBLIC_ prefix
  2. Add the Section 6.4 lint to CI to enforce this automatically going forward

Step 4: Scope Variables Properly

  1. Production secrets: Scope to Production only
  2. Preview/staging secrets: Use separate, lower-privilege credentials for Preview — never reuse production credentials
  3. Use branch-specific preview variables when different branches need different configs
  4. Use shared (team-level) variables for consistent cross-project configuration — mark these Sensitive too

Step 5: Local Development Secret Handling

  1. Because Sensitive env vars are not available in Development, do not store local-dev credentials in Vercel env vars
  2. Use an out-of-band secret manager (1Password, HashiCorp Vault, Doppler, .env.local via vercel env pull for OIDC tokens only)
  3. Document the team’s local-secrets workflow in an engineering handbook

Step 6: Implement OIDC Federation (L2)

  1. Replace static cloud credentials with OIDC tokens (see Section 1.4) — eliminates the long-lived credential problem entirely
  2. OIDC provides 60-minute TTL tokens; 45-minute function cache to prevent mid-execution expiry

Time to Complete: ~30 minutes (initial) + time for rotation

Code Implementation

Code Pack: Terraform
hth-vercel-6.01-environment-variable-security.tf View source on GitHub ↗
# --- L1: Configure environment variables with sensitivity flags ---
# for_each cannot iterate a sensitive value; the variable NAMES are not secret.
resource "vercel_project_environment_variable" "secrets" {
  for_each = nonsensitive(toset(keys(var.environment_variables)))

  project_id = var.project_id
  team_id    = var.vercel_team_id
  key        = each.key
  value      = var.environment_variables[each.key].value
  target     = var.environment_variables[each.key].target
  sensitive  = var.environment_variables[each.key].sensitive
}

# --- L2: team-level policy, applied through vercel_team_config.hardened
#     (hth-vercel-1.01) so only one resource manages the team ---
locals {
  # Enforce Sensitive environment variables team-wide. The provider marks this
  # attribute deprecated in 5.x without a replacement.
  sensitive_env_policy = var.profile_level >= 2 ? "on" : null

  # Hide IP addresses in observability and in Drains (privacy hardening)
  hide_ip_addresses = var.profile_level >= 2 ? true : null
}

Validation & Testing

  1. Enforce Sensitive Environment Variables toggle is Enabled at Team level
  2. Every environment variable in every project has the Sensitive tag (production + preview)
  3. No NEXT_PUBLIC_ variables contain secret values
  4. Production secrets not accessible in preview environment
  5. Every secret value is ≥32 characters, so build-log redaction engages; any shorter legacy secret has been rotated
  6. Local dev workflow does not depend on Vercel-stored Development env vars for secrets
  7. OIDC federation active for cloud provider access (L2)

Expected result: Every secret in every environment is either Sensitive-flagged or replaced by OIDC federation; no secret is readable from the dashboard or API after creation.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.7 Logical access controls, protection of sensitive information
NIST 800-53 SC-28, SC-12 Protection of information at rest, cryptographic key management
ISO 27001 A.10.1.2, A.9.4.3 Key management, password management system
PCI DSS 3.4, 8.2.1 Render PAN unreadable, strong credential storage

6.2 Deployment Retention Policy

Profile Level: L2 (Walk)

NIST 800-53: SI-12

Description

Configure deployment retention policies to automatically remove old deployments that may contain outdated secrets or vulnerable code.

Rationale

Why This Matters:

  • Old deployments remain accessible with their original environment variables
  • Retaining deployments indefinitely increases attack surface
  • Compliance frameworks require data retention policies

Attack Prevented: Exploitation of outdated deployments with known vulnerabilities or leaked secrets

ClickOps Implementation

Step 1: Configure Retention

  1. Navigate to: Project Settings → Deployment Retention
  2. Set production retention: 1 year (or per compliance requirement)
  3. Set preview retention: 1 month
  4. Set errored/canceled retention: 1 week

Time to Complete: ~5 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-6.02-deployment-retention-policy.tf View source on GitHub ↗
# --- L2: Limit how long old deployments stay reachable ---
resource "vercel_project_deployment_retention" "policy" {
  count = var.profile_level >= 2 ? 1 : 0

  project_id            = var.project_id
  team_id               = var.vercel_team_id
  expiration_preview    = var.retention_preview
  expiration_production = var.retention_production
  expiration_canceled   = var.retention_canceled
  expiration_errored    = var.retention_errored
}

Validation & Testing

  1. Retention policies set per environment type
  2. Old deployments automatically cleaned up

Expected result: Deployment history managed with appropriate retention limits

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.5 Disposal of confidential information
NIST 800-53 SI-12 Information management and retention
ISO 27001 A.8.3.2 Disposal of media
PCI DSS 3.1 Data retention and disposal policies

6.3 Rotate Deploy Hooks

Profile Level: L1 (Crawl)

NIST 800-53: IA-5, SA-15

Description

Deploy Hook URLs accept unauthenticated POST requests — the URL is the credential. Any actor with the URL can trigger a deployment of the configured branch. Rotate quarterly, on team membership changes, and whenever a hook URL may have been exposed.

Rationale

Why This Matters:

  • Per Vercel Deploy Hooks docs: “treat with the same security as any other token or password”
  • Deploy hooks committed to a public repo, CI config file, or Slack channel give anyone who reads them the ability to trigger deployments
  • Combined with a Vercel GitHub App that has org-wide repo access, a leaked deploy hook becomes a lateral-movement vector: attacker triggers build → build imports from its configured branch → attacker-controlled branch if the app scope was not restricted

Attack Prevented: Unauthorized deployment triggering, pipeline poisoning via leaked hook URLs, lateral movement through broad GitHub App scope.

Prerequisites

  • Vercel API token with project-scope write
  • Secrets manager (1Password, HashiCorp Vault, AWS Secrets Manager, Doppler) to store rotated URLs
  • Inventory of deploy hooks and their consumers (CI systems, webhook sources)

ClickOps Implementation

Step 1: Inventory

  1. Navigate to: each Project → Settings → Git → Deploy Hooks
  2. Record every hook ID, name, and ref (branch)
  3. Identify the consumer (CI job, partner webhook, internal service) for each hook

Step 2: Rotate

  1. For each hook: create a new hook with the same name + ref, capture the new URL, store in secrets manager
  2. Update every consumer to use the new URL
  3. Verify consumers are succeeding against the new hook
  4. Delete the old hook

Step 3: Harden Vercel GitHub App Scope

  1. Navigate to: **github.com/organizations//settings/installations**
  2. Locate the Vercel GitHub App installation
  3. Change repository access from All repositories to Only select repositories — restrict to the specific repositories that actually deploy to Vercel
  4. This limits the blast radius if a deploy hook URL is abused

Step 4: Scan for Leaked URLs

  1. Search git history, CI configuration files, and documentation for the documented hook URL shape api.vercel.com/v1/integrations/deploy/prj_…/… (regex api\.vercel\.com/v1/integrations/deploy/prj_[A-Za-z0-9]+/[A-Za-z0-9]+)
  2. If any matches are found in files tracked in git, rotate those hooks and remove the URL from git history (git-filter-repo or BFG Repo-Cleaner)

Time to Complete: ~30 minutes per project

Code Implementation

Code Pack: CLI Script
hth-vercel-6.03-rotate-deploy-hooks.sh View source on GitHub ↗
VC=(vercel --scope "${VERCEL_TEAM_ID}")
HOOK_URL_RE='^https://api\.vercel\.com/v1/integrations/deploy/prj_[A-Za-z0-9]+/[A-Za-z0-9]+$'

# --- 1. Inventory existing deploy hooks (the hook URL is never printed) ---
echo "=== Existing Deploy Hooks for ${VERCEL_PROJECT_ID} ==="
"${VC[@]}" deploy-hooks ls --project "${VERCEL_PROJECT_ID}" --format json | \
  jq '(if type == "array" then . else .hooks end)[] | {id, name, ref, createdAt}'

# --- 2. Rotate: create the replacement FIRST, verify it, then remove the old hook ---
# Usage: HTH_HOOK_ID=<hook_id> HTH_HOOK_NAME="ci-deploy" HTH_HOOK_REF="main" ./rotate.sh
rotate_hook() {
  local old_id="$1" name="$2" ref="$3"

  echo ""
  echo "=== Creating replacement hook: ${name} (ref: ${ref}) ==="
  local created new_id new_url url_file
  created="$("${VC[@]}" deploy-hooks create "${name}" --ref "${ref}" \
    --project "${VERCEL_PROJECT_ID}" --non-interactive)"
  new_id="$(echo "${created}" | jq -r '.hook.id // empty')"
  new_url="$(echo "${created}" | jq -r '.hook.url // empty')"
  if [ -z "${new_id}" ] || ! [[ "${new_url}" =~ ${HOOK_URL_RE} ]]; then
    echo "ABORT: replacement hook was not confirmed; old hook ${old_id} left in place." >&2
    return 1
  fi

  # The URL is the credential: write it to an owner-only file, never to stdout.
  url_file="${HTH_HOOK_URL_FILE:-$(umask 077 && mktemp)}"
  (umask 077 && printf '%s\n' "${new_url}" > "${url_file}")
  echo "New hook ${new_id} created; URL written to ${url_file} (mode 600)."
  echo "Move it into your secrets manager, then delete that file."

  echo ""
  echo "=== Removing old hook ${old_id} ==="
  "${VC[@]}" deploy-hooks rm "${old_id}" --project "${VERCEL_PROJECT_ID}" --yes --non-interactive >/dev/null
  if "${VC[@]}" deploy-hooks ls --project "${VERCEL_PROJECT_ID}" --format json | \
      jq -e --arg id "${old_id}" '(if type == "array" then . else .hooks end) | any(.id == $id)' >/dev/null; then
    echo "ERROR: old hook ${old_id} is still present." >&2
    return 1
  fi
  echo "Old hook ${old_id} removed."
}

if [ -n "${HTH_HOOK_ID:-}" ] && [ -n "${HTH_HOOK_NAME:-}" ] && [ -n "${HTH_HOOK_REF:-}" ]; then
  rotate_hook "${HTH_HOOK_ID}" "${HTH_HOOK_NAME}" "${HTH_HOOK_REF}"
else
  echo ""
  echo "To rotate a specific hook, rerun with:"
  echo "  HTH_HOOK_ID=<id> HTH_HOOK_NAME=<name> HTH_HOOK_REF=<branch> $0"
fi

# --- 3. Detect deploy hook URLs committed to git (lists FILES, never the URL) ---
echo ""
echo "=== Scanning tracked files for leaked deploy hook URLs ==="
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  rc=0
  leaked="$(git grep -lE 'api\.vercel\.com/v1/integrations/deploy/prj_[A-Za-z0-9]+/[A-Za-z0-9]+')" || rc=$?
  if [ "${rc}" -eq 0 ]; then
    echo "WARNING: deploy hook URL found in tracked files:"
    echo "${leaked}"
    echo "Action required: rotate these hooks and purge them from history (git-filter-repo or BFG)."
    exit 1
  elif [ "${rc}" -eq 1 ]; then
    echo "No deploy hook URLs found in tracked files."
  else
    echo "ERROR: git grep failed (exit ${rc}); the scan did not run." >&2
    exit "${rc}"
  fi
else
  echo "(Skipping — not inside a git work tree.)"
fi

Validation & Testing

  1. Every deploy hook URL is stored only in a secrets manager — not in git-tracked files
  2. All deploy hook consumers succeed with rotated URLs
  3. Vercel GitHub App is restricted to specific repositories, not org-wide
  4. git log -p -G 'api\.vercel\.com/v1/integrations/deploy/' | head returns only historical, rotated URLs

Expected result: Deploy hook URLs behave like credentials — stored in a vault, rotated on schedule, never committed to git.

Monitoring & Maintenance

  • Quarterly: Rotate every active deploy hook
  • On event: Rotate immediately when any team member with hook URL access leaves
  • On event: Rotate after any incident that might have exposed CI logs or config files

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.7 Logical access, credential management
NIST 800-53 IA-5, SA-15 Authenticator management, development process
ISO 27001 A.9.2.4, A.9.4.3 Management of secret authentication information; password management
PCI DSS 8.2.4 Change authentication credentials at least every 90 days

6.4 Block NEXT_PUBLIC_ Secret Leaks in CI

Profile Level: L1 (Crawl)

NIST 800-53: SC-28, SA-11, SA-15

Description

Add a CI/pre-commit check that fails the build if any environment variable prefixed NEXT_PUBLIC_ also carries a secret-shaped name. Because Next.js inlines NEXT_PUBLIC_* values into the client JavaScript bundle, any secret accidentally prefixed this way ships to every browser and is indexable by search engines.

Rationale

Why This Matters:

  • Cremit research (2025) identified live API keys in 0.45% of public Vercel deployments via exactly this vector
  • The mistake is an easy off-by-one from a correct configuration; pre-deploy lint catches it before it ships
  • Vercel’s own environment variable UI cannot detect this — the NEXT_PUBLIC_ semantics live in Next.js, not Vercel’s validation layer

Attack Prevented: Client-side secret exposure, automated secret-scanner-driven credential theft, search-engine-indexed API keys.

Prerequisites

  • CI system (GitHub Actions, CircleCI, GitLab CI) or pre-commit hook framework
  • grep or rg available in the CI environment (default on all Vercel build containers)

ClickOps Implementation

Step 1: Add the Lint Script to CI

  1. Save the pack script (hth-vercel-6.04-block-next-public-secret-leaks.sh) into your repo at scripts/ci/check-next-public-secrets.sh
  2. Add a required CI step that runs the script before vercel build
  3. Fail the build if the script exits non-zero

Step 2: Add a Pre-Commit Hook (Developer-side)

  1. Install a pre-commit framework (e.g., pre-commit.com)
  2. Register the script to run on every commit touching .env*, next.config.*, or vercel.json
  3. Developers get immediate local feedback before pushing

Step 3: Review Compiled Bundle

  1. After every production build, run the script’s bundle-scan mode against the .next/ output
  2. Any NEXT_PUBLIC_* name matching a secret pattern surfaces in the build log

Step 4: Quarterly Spot-Check

  1. Fetch the production site’s main JavaScript bundle with curl
  2. grep -o 'NEXT_PUBLIC_[A-Z0-9_]*' on the bundle
  3. Confirm no secret-shaped names are present

Time to Complete: ~15 minutes

Code Implementation

Code Pack: Config
hth-vercel-6.04-block-next-public-secret-leaks.sh View source on GitHub ↗
# Names that commonly hold secrets. If any are prefixed NEXT_PUBLIC_, fail.
# Patterns match variable NAMES (pre-equals or pre-colon), not values.
SECRET_NAME_PATTERNS=(
  "SECRET"
  "PRIVATE"
  "API_KEY"
  "APIKEY"
  "TOKEN"
  "PASSWORD"
  "PASSWD"
  "CREDENTIAL"
  "CLIENT_SECRET"
  "WEBHOOK_SECRET"
  "SIGNING_KEY"
  "PRIVATE_KEY"
  "DATABASE_URL"
  "DB_URL"
  "DB_PASSWORD"
  "AWS_SECRET_ACCESS_KEY"
  "SERVICE_ACCOUNT"
  "OAUTH_SECRET"
  "SESSION_SECRET"
  "JWT_SECRET"
  "ENCRYPTION_KEY"
  "STRIPE_SECRET"
  "SENDGRID_API_KEY"
  "OPENAI_API_KEY"
  "ANTHROPIC_API_KEY"
)

# Build a single case-insensitive alternation
PATTERN="$(printf '%s|' "${SECRET_NAME_PATTERNS[@]}")"
PATTERN="${PATTERN%|}"
REGEX="NEXT_PUBLIC_[A-Z0-9_]*(${PATTERN})"

EXIT_CODE=0

echo "=== Scanning for NEXT_PUBLIC_ prefix on secret-shaped names ==="
# The files that declare env vars are mostly HIDDEN or git-ignored (.env*,
# .github/workflows/), so both scanners are told to include them explicitly.
if command -v rg >/dev/null 2>&1; then
  SEARCH_CMD=(rg --hidden --no-ignore --no-heading --line-number -i
    --glob '!.git/**' --glob '!node_modules/**' --glob '!.next/**' -e "${REGEX}" .)
else
  SEARCH_CMD=(grep -rn -iE --exclude-dir=.git --exclude-dir=node_modules
    --exclude-dir=.next "${REGEX}" .)
fi

rc=0
matches="$("${SEARCH_CMD[@]}")" || rc=$?
case "${rc}" in
  0)
    echo "BLOCK: NEXT_PUBLIC_<secret-name> pattern detected — these values ship to the browser:"
    echo "${matches}"
    EXIT_CODE=1
    ;;
  1) ;;
  *)
    echo "ERROR: ${SEARCH_CMD[0]} exited ${rc}; the scan did not complete." >&2
    exit 2
    ;;
esac

# --- Audit the current build output for any NEXT_PUBLIC_* that resembles a secret ---
if [ -d ".next" ]; then
  echo ""
  echo "=== Scanning compiled .next bundle for secret-shaped NEXT_PUBLIC_ values ==="
  # grep exits 1 for "no match" and 2 when it could not read the bundle; only
  # the first means clean.
  brc=0
  bundle_matches="$(grep -rho "NEXT_PUBLIC_[A-Z0-9_]*" .next)" || brc=$?
  case "${brc}" in
    0)
      bundle_matches="$(printf '%s\n' "${bundle_matches}" | sort -u)"
      echo "NEXT_PUBLIC_ variables found in client bundle:"
      echo "${bundle_matches}"
      if echo "${bundle_matches}" | grep -qiE "(${PATTERN})"; then
        echo "BLOCK: secret-shaped NEXT_PUBLIC_ variable present in built bundle."
        EXIT_CODE=1
      fi
      ;;
    1) ;;
    *)
      echo "ERROR: grep exited ${brc} reading .next; the bundle scan did not complete." >&2
      exit 2
      ;;
  esac
fi

if [ "${EXIT_CODE}" -eq 0 ]; then
  echo "OK: no NEXT_PUBLIC_<secret> patterns detected."
fi

exit "${EXIT_CODE}"

Validation & Testing

  1. The lint script exits non-zero when a test commit introduces NEXT_PUBLIC_SECRET_KEY=foo
  2. CI blocks merges that trigger the failure
  3. A fetch of the production bundle shows no secret-named NEXT_PUBLIC_* identifiers
  4. Pre-commit hook fires on local commits modifying env-var-carrying files

Expected result: Secret-shaped NEXT_PUBLIC_* variables are structurally blocked from entering the codebase or a production bundle.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC8.1 Logical access, change management controls
NIST 800-53 SA-11, SA-15, SC-28 Developer security testing, development process, information at rest
ISO 27001 A.14.2.1, A.14.2.5 Secure development policy, secure system engineering principles
PCI DSS 6.3, 6.5 Secure development, training developers on secure coding

7. Domain & Certificate Security

7.1 Prevent Subdomain Takeover

Profile Level: L1 (Crawl)

NIST 800-53: CM-8, SC-20

Description

Audit DNS records to prevent subdomain takeover vulnerabilities when CNAME records point to Vercel without active deployments.

Rationale

Why This Matters:

  • Dangling DNS records pointing to Vercel can be claimed by attackers
  • Subdomain takeover enables phishing, cookie theft, and CSP bypass
  • Security researchers actively scan for Vercel subdomain takeover opportunities

Attack Prevented: Subdomain takeover, phishing via legitimate domain, cookie scope exploitation

Real-World Incidents:

  • Multiple Vercel subdomain takeover reports on HackerOne and Medium demonstrating exploitation of dangling CNAME records

ClickOps Implementation

Step 1: Audit DNS Records

  1. Navigate to: Team Settings → Domains
  2. Review all configured domains
  3. Identify any domains not actively assigned to projects

Step 2: Clean Up Dangling Records

  1. Remove DNS CNAME records for decommissioned Vercel projects
  2. Remove Vercel domain assignments when projects are deleted
  3. Verify all domains resolve to active deployments

Step 3: Monitor Domain Health

  1. Periodically scan for dangling DNS records using DNS auditing tools
  2. Set up alerts for domain configuration changes via audit logs

Time to Complete: ~15 minutes

Code Implementation

Code Pack: CLI Script
hth-vercel-7.01-prevent-subdomain-takeover.sh View source on GitHub ↗
if ! command -v dig >/dev/null 2>&1; then
  echo "ERROR: dig is not installed -- no DNS record can be checked (exit 2)." >&2
  exit 2
fi

VC=(vercel --scope "${VERCEL_TEAM_ID}")

# --- Collect every domain in the team (all pages) ---
echo "=== Vercel Domain Inventory ==="
DOMAINS=""
NEXT=""
COMPLETE=0
for _page in $(seq 1 50); do
  if [ -n "${NEXT}" ]; then
    PAGE_JSON="$("${VC[@]}" domains ls --format json --next "${NEXT}")"
  else
    PAGE_JSON="$("${VC[@]}" domains ls --format json)"
  fi
  # An empty stdout (the table went to stderr) must not read as "no domains".
  if ! printf '%s' "${PAGE_JSON}" | jq -e '.domains | type == "array"' >/dev/null; then
    echo "ERROR: vercel domains ls returned no JSON domain list (exit 2)." >&2
    exit 2
  fi
  DOMAINS+="$(printf '%s' "${PAGE_JSON}" | jq -r '.domains[].name')"$'\n'
  NEXT="$(printf '%s' "${PAGE_JSON}" | jq -r '.pagination.next // empty')"
  if [ -z "${NEXT}" ] || [ "$(printf '%s' "${PAGE_JSON}" | jq '.domains | length')" -lt 20 ]; then
    COMPLETE=1
    break
  fi
done
if [ "${COMPLETE}" -ne 1 ]; then
  echo "ERROR: more than 50 pages of domains -- the inventory is incomplete (exit 2)." >&2
  exit 2
fi

# --- Add DNS names from the command line (the dangling-record case) ---
for name in "$@"; do
  if ! [[ "${name}" =~ ^[A-Za-z0-9*][A-Za-z0-9.-]*$ ]]; then
    echo "ERROR: '${name}' is not a DNS name (exit 2)." >&2
    exit 2
  fi
  DOMAINS+="${name}"$'\n'
done
DOMAINS="$(printf '%s' "${DOMAINS}" | awk 'NF && !seen[$0]++')"
TOTAL="$(printf '%s' "${DOMAINS}" | awk 'NF' | wc -l | tr -d ' ')"
printf '%s\n' "${DOMAINS}"
echo "Total: ${TOTAL} name(s)"

# --- One verdict line per name: no CNAME, not Vercel, OK, WARNING, or UNCHECKED ---
echo ""
echo "=== Checking for Dangling DNS Records ==="
FINDINGS=0
CHECKED=0
UNCHECKED=0
while IFS= read -r domain; do
  [ -n "${domain}" ] || continue
  dig_rc=0
  cname="$(dig +short +time=5 +tries=2 CNAME "${domain}" 2>/dev/null)" || dig_rc=$?
  # dig reports "no servers could be reached" on stdout with exit 9.
  if [ "${dig_rc}" -ne 0 ] || [[ "${cname}" == *";;"* ]]; then
    echo "  UNCHECKED: ${domain} -- dig failed (exit ${dig_rc})"
    UNCHECKED=$((UNCHECKED + 1))
    continue
  fi
  CHECKED=$((CHECKED + 1))
  cname_lc="$(printf '%s' "${cname}" | tr '[:upper:]' '[:lower:]')"
  case "${cname_lc}" in
    "")
      echo "  no CNAME: ${domain}" ;;
    *vercel*|*now.sh*)
      # curl prints 000 itself when it cannot connect, so its exit status is
      # ignored here rather than appending a second code to the first.
      http_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://${domain}" 2>/dev/null)" || true
      [ -n "${http_code}" ] || http_code="000"
      if [ "${http_code}" = "000" ] || [ "${http_code}" = "404" ]; then
        echo "  WARNING: ${domain} has CNAME to Vercel but returns ${http_code} -- possible takeover risk!"
        FINDINGS=1
      else
        echo "  OK: ${domain} -> ${cname} (HTTP ${http_code})"
      fi ;;
    *)
      echo "  not Vercel: ${domain} -> ${cname}" ;;
  esac
done < <(printf '%s\n' "${DOMAINS}")

echo ""
echo "Checked ${CHECKED} of ${TOTAL} name(s); ${UNCHECKED} could not be checked."
if [ "${UNCHECKED}" -ne 0 ] || [ "${CHECKED}" -ne "${TOTAL}" ]; then
  echo "ERROR: the audit did not check every name (exit 2)." >&2
  exit 2
fi

# --- Remove a domain no longer in use (run deliberately, one at a time) ---
# vercel domains rm "unused-subdomain.example.com" --scope "${VERCEL_TEAM_ID}"

exit "${FINDINGS}"

Validation & Testing

  1. All DNS records pointing to Vercel have active deployments
  2. No orphaned domain entries in Vercel dashboard
  3. Domain configuration changes logged in audit log

Expected result: No dangling DNS records vulnerable to subdomain takeover

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 CM-8, SC-20 Component inventory, secure name resolution
ISO 27001 A.13.1.1 Network controls
PCI DSS 2.4 Maintain inventory of system components

7.2 Harden TLS and Certificate Configuration

Profile Level: L1 (Crawl)

NIST 800-53: SC-8, SC-13

Description

Verify TLS configuration and optionally deploy custom certificates for domains requiring specific certificate authorities.

Rationale

Why This Matters:

  • Vercel automatically provides TLS 1.2/1.3 with strong ciphers and forward secrecy
  • HSTS is automatic for all domains but custom domains lack includeSubDomains and preload
  • Post-quantum key exchange (X25519MLKEM768) available for supporting browsers
  • Custom certificates needed for CAA/CT policy compliance in some organizations

Attack Prevented: Man-in-the-middle attacks, protocol downgrade attacks, certificate impersonation

ClickOps Implementation

Step 1: Verify TLS Configuration

  1. Confirm HTTPS enforced (automatic – HTTP 308 redirects to HTTPS)
  2. Verify TLS 1.2+ in use via SSL Labs test
  3. Confirm forward secrecy enabled on all ciphers

Step 2: Enhance HSTS for Custom Domains (L2)

  1. Add custom header: Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  2. Submit custom domain to HSTS Preload list at hstspreload.org

Step 3: Deploy Custom Certificates (L3)

  1. Use vercel certs issue [domain] for custom certificate management
  2. Upload organization-specific certificates if required by policy

Time to Complete: ~10 minutes

Code Implementation

Code Pack: CLI Script
hth-vercel-7.02-harden-tls-certificate-config.sh View source on GitHub ↗
DOMAIN="${1:-}"
if [ -z "${DOMAIN}" ]; then
  echo "Usage: $0 <your-domain.com>" >&2
  exit 2
fi
if ! [[ "${DOMAIN}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
  echo "ERROR: '${DOMAIN}' is not a DNS name (exit 2)." >&2
  exit 2
fi
FINDINGS=0
UNCHECKED=0

# OpenSSL will not offer TLS 1.0/1.1 above security level 0; LibreSSL offers
# them by default and rejects the @SECLEVEL syntax.
LEGACY_ARGS=()
case "$(openssl version 2>/dev/null)" in
  OpenSSL*) LEGACY_ARGS=(-cipher 'DEFAULT:@SECLEVEL=0') ;;
esac

# s_client transcript -> "<protocol> <cipher>" when a handshake completed, else "".
tls_result() {
  printf '%s\n' "$1" | awk '
    /^New, .*Cipher is / && c == "" { c = $0; sub(/.*Cipher is /, "", c) }
    /^ *Protocol *:/ && p == "" { p = $0; sub(/^ *Protocol *: */, "", p); sub(/ .*/, "", p) }
    END { if (c != "" && c != "(NONE)" && p != "") print p, c }'
}

echo "=== TLS Verification for ${DOMAIN} ==="

# --- Negotiated protocol and cipher ---
echo "--- Negotiated protocol and cipher ---"
tls_out="$(openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" < /dev/null 2>&1)" || true
negotiated="$(tls_result "${tls_out}")"
case "${negotiated%% *}" in
  "") echo "  UNCHECKED: no TLS handshake completed with ${DOMAIN}:443"; UNCHECKED=1 ;;
  SSLv3|TLSv1|TLSv1.1) echo "  WARNING: the default handshake negotiated ${negotiated}"; FINDINGS=1 ;;
  *) echo "  OK: ${negotiated}" ;;
esac

# --- TLS 1.0 / 1.1 must be refused by the server (Validation 2) ---
echo "--- Legacy protocols (must be refused) ---"
for proto in tls1 tls1_1; do
  legacy_out="$(openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" "-${proto}" \
    ${LEGACY_ARGS[@]+"${LEGACY_ARGS[@]}"} < /dev/null 2>&1)" || true
  accepted="$(tls_result "${legacy_out}")"
  if [ -n "${accepted}" ]; then
    echo "  WARNING: ${DOMAIN} completed a ${proto} handshake (${accepted})"
    FINDINGS=1
  elif printf '%s\n' "${legacy_out}" | \
       grep -iE 'alert (protocol version|handshake failure)|unsupported protocol|wrong (ssl )?version' > /dev/null; then
    echo "  OK: ${proto} refused by the server"
  else
    # e.g. the local openssl cannot offer ${proto} ("no protocols available")
    echo "  UNCHECKED: ${proto} probe inconclusive"
    UNCHECKED=1
  fi
done

# --- HSTS ---
echo "--- HSTS ---"
if headers="$(curl -sSI --max-time 15 "https://${DOMAIN}" 2>/dev/null)"; then
  hsts="$(printf '%s\n' "${headers}" | tr -d '\r' | awk '
    tolower($0) ~ /^strict-transport-security:/ && v == "" { v = $0; sub(/^[^:]*: */, "", v) }
    END { print v }')"
  max_age="$(printf '%s\n' "${hsts}" | awk '
    match(tolower($0), /max-age=[0-9]+/) { print substr($0, RSTART + 8, RLENGTH - 8) }')"
  hsts_lc="$(printf '%s' "${hsts}" | tr '[:upper:]' '[:lower:]')"
  if [ -z "${hsts}" ]; then
    echo "  WARNING: no Strict-Transport-Security header"
    FINDINGS=1
  elif [ -z "${max_age}" ] || [ "${max_age}" -lt 31536000 ]; then
    echo "  WARNING: HSTS max-age is below one year: ${hsts}"
    FINDINGS=1
  elif [ "${HTH_HSTS_PRELOAD:-0}" = "1" ] && \
       { [[ "${hsts_lc}" != *includesubdomains* ]] || [[ "${hsts_lc}" != *preload* ]]; }; then
    echo "  WARNING: L2 HSTS needs includeSubDomains and preload: ${hsts}"
    FINDINGS=1
  else
    echo "  OK: ${hsts}"
  fi
else
  echo "  UNCHECKED: HEAD https://${DOMAIN} failed"
  UNCHECKED=1
fi

# --- HTTP to HTTPS redirect (curl prints 000 itself when it cannot connect) ---
echo "--- HTTP redirect ---"
redirect="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "http://${DOMAIN}" 2>/dev/null)" || true
[ -n "${redirect}" ] || redirect="000"
case "${redirect}" in
  301|308) echo "  OK: HTTP redirects to HTTPS (${redirect})" ;;
  000) echo "  UNCHECKED: http://${DOMAIN} did not answer"; UNCHECKED=1 ;;
  *) echo "  WARNING: HTTP returned ${redirect} -- expected a 308 redirect"; FINDINGS=1 ;;
esac

# --- Issue custom certificate (L3, MUTATING -- run deliberately) ---
# vercel certs issue "${DOMAIN}" --scope "${VERCEL_TEAM_ID}"

# --- Certificate inventory for the TEAM, every page ---
echo ""
echo "=== Certificate Inventory ==="
VC=(vercel --scope "${VERCEL_TEAM_ID}")
NEXT=""
COMPLETE=0
ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/hth-certs.XXXXXX")"
trap 'rm -f "${ERR_FILE}"' EXIT
for _page in $(seq 1 50); do
  NEXT_ARGS=()
  [ -z "${NEXT}" ] || NEXT_ARGS=(--next "${NEXT}")
  if ! "${VC[@]}" certs ls ${NEXT_ARGS[@]+"${NEXT_ARGS[@]}"} 2> "${ERR_FILE}"; then
    cat "${ERR_FILE}" >&2
    echo "ERROR: vercel certs ls failed -- the certificate inventory is incomplete (exit 2)." >&2
    exit 2
  fi
  cat "${ERR_FILE}" >&2
  NEXT="$(grep -oE -- '--next [0-9]+' "${ERR_FILE}" | awk '{ n = $2 } END { print n }')" || true
  if [ -z "${NEXT}" ]; then
    COMPLETE=1
    break
  fi
done
if [ "${COMPLETE}" -ne 1 ]; then
  echo "ERROR: more than 50 pages of certificates -- the inventory is incomplete (exit 2)." >&2
  exit 2
fi

echo ""
echo "Findings: ${FINDINGS}; probes that could not complete: ${UNCHECKED}"
if [ "${UNCHECKED}" -ne 0 ]; then
  exit 2
fi
exit "${FINDINGS}"

Validation & Testing

  1. SSL Labs grade A+ with HSTS preloading
  2. No TLS 1.0/1.1 negotiation possible
  3. All ciphers support forward secrecy
  4. HSTS preload header present on custom domains (L2)

Expected result: Strong TLS configuration with HSTS across all domains

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.7 Encryption of data in transit
NIST 800-53 SC-8, SC-13 Transmission confidentiality, cryptographic protection
ISO 27001 A.10.1.1 Policy on use of cryptographic controls
PCI DSS 4.1 Strong cryptography for transmission of cardholder data

8. Monitoring & Detection

8.1 Configure Drains for SIEM

Profile Level: L1 (Crawl)

NIST 800-53: AU-2, AU-6

Description

Forward Vercel runtime, build, and firewall logs to your SIEM via Drains (formerly “Log Drains”) for security monitoring and incident response. Vercel’s Drains pipeline supports four data types with distinct schemas — configure one drain per data type.

Rationale

Why This Matters:

  • Vercel only retains runtime logs short-term — Drains are required for long-term retention and regulatory compliance
  • Firewall logs capture blocked/challenged requests, persistent actions, and JA3/JA4 fingerprints for threat intelligence
  • Drain payloads are signed with HMAC-SHA1 via the x-vercel-signature header; Section 8.4 covers constant-time verification
  • SIEM integration enables correlation with other security data sources

Attack Prevented: Undetected attacks, delayed incident response, evidence loss, compliance gaps in log retention.

Drains Schema Catalog (Primary Source)

Per Vercel Drains docs, each drain handles one data type and schema version:

Schema name Version Data type
log v1 Runtime, build, and static logs
trace v1 Distributed tracing (OpenTelemetry)
analytics v2 Web Analytics page views and custom events
speed_insights v1 Performance metrics and web vitals

Specify the desired schema via the REST API schemas property when creating or validating a drain.

Prerequisites

  • Vercel Pro or Enterprise plan (Hobby not supported; $0.50 per drain volume unit)
  • SIEM endpoint accepting HTTPS POST with JSON payloads
  • Secrets manager to store the per-drain rotatable HMAC secret

ClickOps Implementation

Step 1: Create a Log Drain

  1. Navigate to: Team Settings → Drains → Create Drain
  2. Schema: log v1
  3. Destination: custom HTTPS endpoint (or native integration for Dash0 / Braintrust)
  4. Environments: Production and Preview
  5. Sources: static, edge, external, build, lambda, firewall
  6. Generate and record a strong shared secret; store in your secrets manager

Step 2: Create a Separate Firewall Log Drain (L2)

  1. Because firewall logs are high-signal security events, route them to a dedicated destination (or a security-specific index in your SIEM)
  2. Create a second drain with schema log v1, sources = [firewall]

Step 3: Configure Trace Drain (L2)

  1. Create a third drain with schema trace v1 for distributed tracing (OpenTelemetry format)
  2. Useful for latency investigations and correlating security events with application spans

Step 4: Enable IP Address Visibility Control (GDPR Hardening)

  1. Navigate to: Team Settings → Security & Privacy → IP Address Visibility
  2. Toggle Hide IP addresses in Drains to Enabled if IP addresses are classified as personal data under your applicable privacy regime (EU GDPR, UK GDPR)
  3. This strips public IPs from drain payloads before delivery

Step 5: Configure Sampling (Optional)

  1. For high-volume projects, set per-drain sampling
  2. Use 1.0 (100%) for security-critical projects (firewall, audit)
  3. Lower rates acceptable for development/preview

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Terraform
hth-vercel-8.01-configure-log-drains-siem.tf View source on GitHub ↗
# --- L1: Configure log drain to forward deployment and runtime logs ---
resource "vercel_log_drain" "security_logging" {
  count = var.log_drain_endpoint != "" ? 1 : 0

  name            = "hth-security-log-drain"
  team_id         = var.vercel_team_id
  delivery_format = "json"
  endpoint        = var.log_drain_endpoint
  environments    = var.log_drain_environments
  sources         = var.log_drain_sources
  secret          = var.log_drain_secret != "" ? var.log_drain_secret : null
}

# --- L2: Separate firewall log drain for WAF activity ---
resource "vercel_log_drain" "firewall_logging" {
  count = var.profile_level >= 2 && var.log_drain_endpoint != "" ? 1 : 0

  name            = "hth-firewall-log-drain"
  team_id         = var.vercel_team_id
  delivery_format = "json"
  endpoint        = var.log_drain_endpoint
  environments    = ["production", "preview"]
  sources         = ["firewall"]
  secret          = var.log_drain_secret != "" ? var.log_drain_secret : null
}

Validation & Testing

  1. Log drain receiving events in SIEM
  2. Payload signature verification working
  3. Firewall logs appearing for blocked requests
  4. All configured environments and sources flowing

Expected result: All Vercel logs forwarded to SIEM with cryptographic verification

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.2, CC7.3 System monitoring, anomaly detection
NIST 800-53 AU-2, AU-6 Audit events, audit review and analysis
ISO 27001 A.12.4.1 Event logging
PCI DSS 10.2 Implement automated audit trails

8.2 Enable Audit Logging with SIEM Streaming

Profile Level: L2 (Walk)

NIST 800-53: AU-2, AU-3, AU-12

Description

Enable enterprise audit logging and forward it to your SIEM as an Audit Log Drain to track all administrative actions, configuration changes, and security events.

Rationale

Why This Matters:

  • Audit logs capture 90 days of immutable administrative activity
  • Tracks: member changes, environment variable CRUD, deployment protection changes, domain changes, integration installs, and more
  • Real-time forwarding enables alerting on security-relevant events instead of after-the-fact CSV review
  • CSV export available for compliance reporting

Attack Prevented: Undetected administrative compromise, unauthorized configuration changes, insider threat

Deprecated: Custom SIEM Log Streaming

The standalone “Custom SIEM Log Streaming” configuration under Team Settings → Security & Privacy → Audit Log → Configure is obsolete as of 2026-08-07 and has been replaced by Audit Log Drains (Vercel changelog). Audit logs now ride the same Drains surface as runtime, trace, analytics, and Speed Insights data (Sections 8.1 and 8.4) — which also means the drain signature verification in Section 8.4 now applies to audit log delivery, not just runtime logs. If your team configured the legacy streaming path, migrate it to a drain; the destination list and delivery guarantees are not identical.

Prerequisites

  • Vercel Enterprise plan (Audit Log Drains remain Enterprise-only)
  • SIEM or object-store destination: Amazon S3, Splunk, Datadog, Panther, or a custom HTTPS endpoint
  • Secrets manager to store the per-drain HMAC secret (Section 8.4)

ClickOps Implementation

Step 1: Access Audit Log

  1. Navigate to: Team Settings → Security & Privacy → Audit Log
  2. Review available event types and current activity

Step 2: Create an Audit Log Drain

  1. Navigate to: Team Settings → Drains → Add Drain
  2. Select data type Audit Log
  3. Select destination: Amazon S3, Splunk, Datadog, Panther, or a custom HTTPS endpoint
  4. Configure destination authentication (API key, header-based, or AWS credentials)
  5. Record the drain secret in your secrets manager and verify deliveries per Section 8.4
  6. Via the REST API the same drain is created with the schemas property set to audit_log version v1; pre-flight a custom endpoint with the validate-drain-delivery-configuration call before going live

Step 3: Build Detection Rules

  1. Create alerts for critical events, using the event names from Vercel’s Activity Log event table: team-member-role-update, team-member-add, env-variable-add, env-variable-read, project-sso-protection, project-password-protection, password-protection-disabled, saml-connection-created, saml-connection-deleted, user-token-created
  2. Alert on passport-access-granted — successful Passport authentications (Section 2.1) are written to both the Activity Log and Audit Logs with the visitor, protected hostname, and project, making them the authoritative record of who reached a Passport-protected deployment
  3. Treat sensitive-environment-variable redaction events (Section 6.1) as a signal: they name the key, project, and deployment whose build log contained a secret
  4. Monitor for unusual patterns: bulk member additions, env var decryption events, integration installs

Time to Complete: ~30 minutes

Code Implementation

Code Pack: API Script
hth-vercel-8.02-audit-logging-siem-streaming.sh View source on GitHub ↗
# curl -f: an HTTP 4xx/5xx aborts instead of reporting an empty event list.
vercel_get() {
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" "https://api.vercel.com$1"
}

# Security-critical event types to alert on (names as documented in the
# Activity Log event table).
CRITICAL_TYPES="team-member-role-update,team-member-add,team-member-delete"
CRITICAL_TYPES+=",env-variable-add,env-variable-edit,env-variable-read"
CRITICAL_TYPES+=",project-sso-protection,project-password-protection,password-protection-disabled"
CRITICAL_TYPES+=",saml-connection-created,saml-connection-deleted"
CRITICAL_TYPES+=",integration-installation-completed,domain,user-token-created"

# --- Recent security-critical events, counted by type ---
echo "=== Security-Critical Events (last 100) ==="
EVENTS_JSON="$(vercel_get "/v3/events?teamId=${VERCEL_TEAM_ID}&limit=100&types=${CRITICAL_TYPES}")"
echo "${EVENTS_JSON}" | jq '[.events[] | .type] | group_by(.) | map({type: .[0], count: length})'

echo ""
echo "=== Most Recent 10 ==="
echo "${EVENTS_JSON}" | jq '[.events[]][:10][] | {id, type, createdAt, principalId, text}'

# --- Drains carrying audit/log data (endpoint host only: a drain's full
#     delivery object can carry its signature secret and auth headers) ---
echo ""
echo "=== Drain Status ==="
vercel_get "/v1/drains?teamId=${VERCEL_TEAM_ID}" | jq '.drains[] | {
  id, name, status, source,
  schemas: (.schemas // {} | keys),
  deliveryType: .delivery.type,
  endpointHost: (.delivery.endpoint | if type == "string" then (capture("^(?<h>[a-z]+://[^/?#]+)").h // "unparsed") else "non-http" end)
}'

Validation & Testing

  1. Audit log shows recent administrative events
  2. The Audit Log Drain is delivering events to the SIEM in real time (legacy Custom SIEM Log Streaming has been retired)
  3. Drain payload signatures validate per Section 8.4
  4. Detection rules firing on test events, including a test passport-access-granted event if Passport is in use
  5. CSV export produces valid compliance report

Expected result: All administrative actions logged, delivered over a signature-verified Audit Log Drain, and alerted on

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.2 Monitor system components for anomalies
NIST 800-53 AU-2, AU-3, AU-12 Audit events, content, generation
ISO 27001 A.12.4.1, A.12.4.3 Event logging, administrator and operator logs
PCI DSS 10.1, 10.5 Audit trails, secure audit trails

8.3 Cron Job Security

Profile Level: L1 (Crawl)

NIST 800-53: AC-3, SI-10

Description

Secure cron job endpoints with the CRON_SECRET mechanism to prevent unauthorized invocation.

Rationale

Why This Matters:

  • Cron endpoints are publicly accessible URLs without protection
  • Without CRON_SECRET verification, anyone can trigger cron jobs
  • Compromised cron endpoints enable unauthorized data processing or exfiltration

Attack Prevented: Unauthorized cron invocation, data exfiltration via scheduled jobs, resource abuse

ClickOps Implementation

Step 1: Generate Strong CRON_SECRET

  1. Generate: openssl rand -hex 32 (minimum 16 characters)
  2. Add as production environment variable: CRON_SECRET

Step 2: Verify in Application Code

  1. Check Authorization: Bearer <CRON_SECRET> header in every cron handler
  2. Return 401 for missing or mismatched secrets
  3. Vercel automatically sends the bearer token when invoking cron endpoints

Time to Complete: ~10 minutes

Code Implementation

Code Pack: CLI Script
hth-vercel-8.03-cron-job-security.sh View source on GitHub ↗
DOMAIN="${1:-}"
CRON_PATH="${2:-/api/cron}"

# --- Generate a strong CRON_SECRET and store it as a production env var ---
if [ "${HTH_CONFIRM_ENV_WRITE:-0}" = "1" ]; then
  : "${VERCEL_TOKEN:?Set VERCEL_TOKEN}"
  : "${VERCEL_TEAM_ID:?Set VERCEL_TEAM_ID}"
  : "${VERCEL_PROJECT_ID:?Set VERCEL_PROJECT_ID}"
  # `vercel env add` writes to the project named by VERCEL_ORG_ID + VERCEL_PROJECT_ID
  # when BOTH are set, refuses when only one is, and otherwise writes to whatever
  # project the current directory is linked to. Name the target explicitly.
  export VERCEL_ORG_ID="${VERCEL_TEAM_ID}"
  echo "=== Setting CRON_SECRET (production) on ${VERCEL_PROJECT_ID} ==="
  CRON_SECRET="$(openssl rand -hex 32)"
  printf '%s' "${CRON_SECRET}" | vercel env add CRON_SECRET production
  echo "CRON_SECRET stored in Vercel (value not displayed). Redeploy for it to take effect."
else
  echo "Skipping env write: set HTH_CONFIRM_ENV_WRITE=1 to generate and store CRON_SECRET."
fi

# --- Verify the cron endpoint rejects unauthenticated requests ---
if [ -z "${DOMAIN}" ]; then
  echo "Usage: $0 <your-domain.com> [/api/cron-path]" >&2
  exit 2
fi

echo ""
echo "=== Testing Cron Endpoint Security: https://${DOMAIN}${CRON_PATH} ==="
# curl prints 000 itself when it cannot connect, so its exit status is ignored
# rather than appending a second 000.
http_code="$(curl -s -o /dev/null -w "%{http_code}" "https://${DOMAIN}${CRON_PATH}" 2>/dev/null)" || true
[ -n "${http_code}" ] || http_code="000"
if [ "${http_code}" != "401" ]; then
  echo "FAIL: unauthenticated request returned ${http_code} -- expected 401."
  exit 1
fi
echo "OK: unauthenticated request returns 401"

if [ -n "${CRON_SECRET:-}" ]; then
  http_code="$(curl -s -o /dev/null -w "%{http_code}" \
    -H "Authorization: Bearer ${CRON_SECRET}" \
    "https://${DOMAIN}${CRON_PATH}" 2>/dev/null)" || true
  [ -n "${http_code}" ] || http_code="000"
  echo "Authenticated request: HTTP ${http_code} (expect 2xx once the new secret is deployed)"
fi

Validation & Testing

  1. CRON_SECRET set as production environment variable
  2. Direct HTTP request without bearer token returns 401
  3. Vercel-triggered cron execution succeeds with correct token

Expected result: Cron endpoints only accessible via authenticated Vercel invocation

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 AC-3, SI-10 Access enforcement, information input validation
ISO 27001 A.9.4.1 Information access restriction
PCI DSS 8.1 Unique identification for system components

8.4 Verify Drain Delivery Signatures

Profile Level: L1 (Crawl)

NIST 800-53: SC-8, SC-13, AU-9

Description

Every drain payload Vercel delivers is signed with HMAC-SHA1 via the x-vercel-signature header. The receiver must validate this signature with a constant-time comparison before processing, and must reject unsigned or tampered payloads. This is the mechanism that distinguishes authentic Vercel traffic from forged SIEM ingestion.

Rationale

Why This Matters:

  • An attacker who can reach your SIEM endpoint can inject fake logs — masking their own activity or triggering noisy alerts — unless every delivery is signed and verified
  • Non-constant-time string comparison leaks signature bytes via timing side-channel, enabling forgery over time
  • The HMAC secret is per-drain and rotatable from the Drains UI — treat like any other credential

Attack Prevented: Log injection, forged audit evidence, timing-attack-driven signature recovery.

Prerequisites

  • A receiver endpoint you control (cannot validate signatures on a managed SIEM’s raw ingest URL — typically stand up a small receiver that validates then forwards)
  • Node.js 16+, Python 3.8+, Go 1.18+, or any language with constant-time comparison built-in
  • Access to the per-drain HMAC secret from the Drains dashboard

ClickOps Implementation

Step 1: Generate and Store the Drain Secret

  1. Navigate to: **Team Settings → Drains → → Settings**
  2. Generate a new secret; record it in your secrets manager
  3. The receiver will load this secret from an environment variable, never from disk or source

Step 2: Deploy a Signature-Validating Receiver

  1. Use the reference receiver from the pack below (Node.js) or an equivalent in your stack
  2. Receiver reads raw body, computes hmac_sha1(SECRET, body), compares constant-time to x-vercel-signature
  3. Reject non-matching deliveries with HTTP 401

Step 3: Validate Delivery Configuration

  1. Call POST https://api.vercel.com/v1/drains/test (Validate Drain delivery configuration) with the intended schemas and delivery (type, endpoint, encoding, headers); Vercel sends sample events without creating a drain
  2. Confirm Vercel can reach the receiver and the receiver accepts the signature

Step 4: Configure IP Address Visibility (GDPR)

  1. Navigate to: Team Settings → Security & Privacy → IP Address Visibility
  2. Confirm the hideIpAddresses and hideIpAddressesInLogDrains settings match your privacy posture

Step 5: Rotate Secret Quarterly

  1. From the Drains dashboard, rotate the drain secret
  2. Update the receiver’s environment variable
  3. Allow a short overlap window so in-flight deliveries aren’t lost

Time to Complete: ~30 minutes (initial deployment)

Code Implementation

Code Pack: API Script
hth-vercel-8.04-drain-signature-verification.sh View source on GitHub ↗
# --- Reference receiver (Node.js): verifies x-vercel-signature in constant time ---
RECEIVER="$(mktemp "${TMPDIR:-/tmp}/hth-drain-receiver.XXXXXX")"
cat > "${RECEIVER}" <<'JS'
// HTH reference Drain receiver with signature verification.
// See: https://vercel.com/docs/drains/security
const http = require('node:http');
const crypto = require('node:crypto');

const SECRET = process.env.VERCEL_DRAIN_SECRET;
if (!SECRET) {
  console.error('Set VERCEL_DRAIN_SECRET (matches the drain\'s rotatable secret).');
  process.exit(1);
}

const server = http.createServer((req, res) => {
  if (req.method !== 'POST') return res.writeHead(405).end();

  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    const provided = req.headers['x-vercel-signature'];
    if (!provided || typeof provided !== 'string') {
      return res.writeHead(401).end('missing signature');
    }

    const expected = crypto
      .createHmac('sha1', SECRET)
      .update(body)
      .digest('hex');

    // Constant-time comparison — CRITICAL: prevents timing attacks.
    const a = Buffer.from(provided, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.writeHead(401).end('invalid signature');
    }

    // TODO: forward verified payload to SIEM / object storage.
    process.stdout.write(`OK ${body.length} bytes\n`);
    res.writeHead(200).end('ok');
  });
});

server.listen(process.env.PORT || 8787, () => {
  console.log(`HTH drain receiver listening on :${process.env.PORT || 8787}`);
});
JS

echo "Reference receiver written to ${RECEIVER}"
echo "Run: VERCEL_DRAIN_SECRET=<drain-secret> node ${RECEIVER}"

# --- Validate the intended delivery config with sample events before going live ---
if [ -n "${VERCEL_TOKEN:-}" ] && [ -n "${VERCEL_TEAM_ID:-}" ] && [ -n "${VERCEL_DRAIN_URL:-}" ]; then
  echo ""
  echo "=== Testing drain delivery to ${VERCEL_DRAIN_URL} ==="
  curl -fsS -X POST \
    -H "Authorization: Bearer ${VERCEL_TOKEN}" \
    -H "Content-Type: application/json" \
    "https://api.vercel.com/v1/drains/test?teamId=${VERCEL_TEAM_ID}" \
    -d "$(jq -n --arg url "${VERCEL_DRAIN_URL}" '{
      schemas: { log: { version: "v1" } },
      delivery: { type: "http", endpoint: $url, encoding: "json", headers: {} }
    }')" | jq '.'
fi

# --- Ensure team-wide IP Address Visibility is disabled (GDPR hardening) ---
if [ -n "${VERCEL_TOKEN:-}" ] && [ -n "${VERCEL_TEAM_ID:-}" ]; then
  echo ""
  echo "=== Current team-level IP visibility settings ==="
  curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" \
    "https://api.vercel.com/v2/teams/${VERCEL_TEAM_ID}" | \
    jq '{hideIpAddresses, hideIpAddressesInLogDrains}'
fi

Validation & Testing

  1. Receiver returns 401 for requests with missing or invalid x-vercel-signature
  2. Receiver returns 200 for authentic Vercel deliveries
  3. A deliberately-modified payload is rejected even if x-vercel-signature is present
  4. Constant-time comparison is used (Node crypto.timingSafeEqual, Python hmac.compare_digest, etc.)
  5. Secret rotation rehearsal completes within the allowed overlap window

Expected result: Only authentic, untampered Vercel drain deliveries reach the SIEM.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.2, CC6.1 System monitoring, logical access
NIST 800-53 SC-8, SC-13, AU-9 Transmission confidentiality/integrity, cryptographic protection, protection of audit information
ISO 27001 A.12.4.2, A.10.1.2 Protection of log information, key management
PCI DSS 10.5, 10.5.5 Secure audit trails, use file-integrity monitoring on logs

9. Framework CVE Management (Next.js)

Vercel-hosted apps overwhelmingly run Next.js, a framework Vercel also maintains. Framework vulnerabilities are Vercel-relevant security events because (a) Vercel frequently ships edge-side WAF mitigations before public disclosure, and (b) customers who self-host Next.js elsewhere don’t get those automatic mitigations. This section defines an L1 baseline for staying ahead of Next.js CVEs, independent of the platform configurations in earlier sections.

9.1 Next.js Patch Management & Edge Header Strip

Profile Level: L1 (Crawl)

NIST 800-53: SI-2, RA-5, SI-10

Description

Maintain a defensive posture against Next.js framework CVEs: pin to a patched version, subscribe to advisories, add edge-side WAF rules that strip internal headers exploited by known attacks, and treat middleware as one authorization layer among several rather than the only one.

Rationale

Why This Matters:

  • Multiple critical Next.js CVEs in the last 24 months have had active in-the-wild exploitation within hours of disclosure
  • The most impactful class (middleware bypass, RSC deserialization) abuses internal HTTP headers that should never arrive from the public internet
  • Self-hosted Next.js forks do not receive Vercel’s automatic WAF mitigations — customers off-platform must implement the defenses themselves
  • Vercel’s $1M React2Shell bounty surfaced 20 unique WAF bypasses, confirming that edge protection alone is insufficient — framework patching is mandatory
  • The middleware-bypass class recurs: CVE-2026-64642 (July 2026) bypasses all middleware auth checks on App Router projects using Turbopack with exactly one config.i18n.locales entry — the same outcome as CVE-2025-29927 through an unrelated mechanism, which is the empirical case for Section 10.2’s in-handler authorization

Attack Prevented: Authorization bypass via middleware, RCE via RSC deserialization, SSRF via Server Actions or /_next/image, cache poisoning, source code exposure.

Known Vulnerabilities (verify your pinned version is at or above the fix):

CVE Class Fix Versions ITW? Reference
CVE-2025-29927 Middleware auth bypass 12.3.5, 13.5.9, 14.2.25, 15.2.3 Yes (mass scanning <48h) NVD
CVE-2025-55182 / 66478 (“React2Shell”) RSC RCE (CVSS 10.0) 15.5.7, 16.0.7 Yes (Trend Micro) Next.js advisory
CVE-2025-55183 RSC source exposure Same as React2Shell - Vercel bulletin
CVE-2025-55184 RSC DoS Same as React2Shell - Vercel bulletin
CVE-2026-23869 App Router RSC DoS 15.5.15, 16.2.3 - Vercel changelog
CVE-2026-64641 App Router Server Actions CPU-exhaustion DoS (High) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64642 Middleware/proxy bypass — App Router with Turbopack and exactly one config.i18n.locales entry; bypasses all middleware auth checks (High) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64645 SSRF via rewrites() / redirects() destination hostname built from request-controlled input; on redirects() becomes Open Redirect (High) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64649 SSRF in Server Actions on custom servers via attacker-controlled Host header (High) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64643 Unauthenticated global disclosure of Server Action / use cache endpoint IDs — recon primitive (Medium) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64644 /_next/image DoS via malicious remote SVG (Medium) — see Section 10.1 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64646 Unbounded Server Action payload in the Edge runtime (Medium) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64647 Server-side fetch cache confusion — response body of another request returned (Medium) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2026-64648 Server-side fetch cache confusion (companion to CVE-2026-64647, Medium) 15.5.21, 16.2.11 - Next.js July 2026 security release
CVE-2025-49826 204 cache poisoning DoS 15.1.8 - GHSA
CVE-2024-46982 Pages Router cache poisoning 13.5.7, 14.2.10 - GHSA
CVE-2024-34351 Server Actions SSRF 14.1.1 - Assetnote

Prerequisites

  • Next.js application on Vercel (or self-hosted, in which case all controls below are customer-implemented)
  • Renovate, Dependabot, or equivalent automated-PR dependency manager
  • CI that can run npm audit / pnpm audit on every build
  • WAF Custom Rules available on the plan (Pro+)

ClickOps Implementation

Step 1: Pin Next.js to a Supported LTS Channel

  1. Next.js version policy is now channelled: pin to the Active LTS line (currently 16.2.x) or the Maintenance LTS line (currently 15.5.x) rather than to an arbitrary exact version chosen once and forgotten
  2. Minimum safe versions as of the July 2026 security release: 16.2.11 (Active LTS) or 15.5.21 (Maintenance LTS). Anything below these is vulnerable to the nine CVEs in the table above, including a middleware bypass in the same class as CVE-2025-29927
  3. Commit the lockfile; configure Renovate/Dependabot to propose upgrades within the pinned channel as they are released

Step 2: Subscribe to the Preannounced Security Release Program

  1. Since 2026-07-13, Next.js runs a preannounced monthly security-release program: roughly a month ahead of each release, nextjs.org/blog publishes the expected release date and the highest anticipated severity, so upgrade windows can be scheduled before the patch lands rather than scrambled after (program announcement)
  2. Put the announced dates on the change calendar and pre-book the maintenance window — the whole point of preannouncement is that patch day is no longer a surprise
  3. Ad-hoc out-of-band patches still ship for urgent, in-the-wild cases; the monthly cadence does not replace an emergency path
  4. Subscribe the security team to nextjs.org/blog (security-tagged posts), watch the github.com/vercel/next.js Security Advisories tab, and watch vercel.com/changelog (security tag)
  5. Expect advisory volume to rise: Vercel now runs LLM-assisted vulnerability discovery against the framework (vercel-labs/deepsec) alongside an expanded HackerOne bug bounty. More advisories per year is a sign of the program working, not of the framework degrading — plan capacity accordingly

Step 3: Deploy Edge Header-Strip Rules (Defense in Depth)

  1. Use the Section 3.1 firewall workflow to add a Deny Custom Rule matching requests containing x-middleware-subrequest (CVE-2025-29927 defense in depth, even if you’re patched)
  2. Add a Log Custom Rule for requests containing x-nextjs-data or Next-Action — these are exploit precursors that should not arrive from the public internet in normal operation
  3. Pair with Persistent Actions (Section 3.3) so a single probe triggers a time-boxed block

Step 4: Measure Mean-Time-to-Patch (Clock Starts at Preannouncement)

  1. Under the monthly program the MTTP clock starts at the pre-announcement, not at CVE publication — by the time the advisory is public, a prepared team should already have a scheduled window and a tested upgrade branch
  2. Track two numbers: days from pre-announcement to a merged upgrade branch, and hours from release publication to production deploy
  3. Build time is part of MTTP — per Eduardo Bouças’s analysis, teams with >10-minute builds stayed vulnerable to CVE-2025-29927 longer; optimize build pipelines as a security investment
  4. Target ≤ 72 hours from publication to production for critical (CVSS ≥ 9.0); out-of-band emergency patches keep the same target

Step 5: Defense-in-Depth on Middleware

  1. Never rely on middleware as the sole authorization boundary — see Section 10.2 for the enforcement pattern in Route Handlers, Server Components, and Server Actions

Time to Complete: ~30 minutes (initial) + ongoing

Code Implementation

Code Pack: API Script
hth-vercel-9.01-nextjs-middleware-header-strip.sh View source on GitHub ↗
# WARNING: if the Terraform module manages this project's firewall
# (vercel_firewall_config, 3.1/3.2), its next apply REPLACES the whole config
# and removes these rules. Manage custom rules in one place, not both.

FW_URL="https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}"

# curl -f aborts on HTTP 4xx/5xx; a 2xx body carrying .error also fails the run.
fw_patch() {
  local resp
  resp="$(curl -fsS -X PATCH \
    -H "Authorization: Bearer ${VERCEL_TOKEN}" \
    -H "Content-Type: application/json" \
    "${FW_URL}" -d @-)"
  if echo "${resp}" | jq -e 'type == "object" and has("error")' >/dev/null; then
    echo "${resp}" | jq '.error' >&2
    return 1
  fi
  echo "OK"
}

# A re-run must not duplicate rules (each duplicate also spends the plan's
# custom-rule quota), so a rule whose name already exists in the ACTIVE config is
# updated in place with "rules.update" instead of inserted again.
fw_upsert() {
  local body name id
  body="$(cat)"
  name="$(printf '%s' "${body}" | jq -r '.value.name')"
  id="$(printf '%s' "${ACTIVE_JSON}" | jq -r --arg n "${name}" '[.rules[]? | select(.name == $n) | .id][0] // empty')"
  if [ -n "${id}" ]; then
    echo "(rule ${name} already exists as ${id}: updating it in place)"
    printf '%s' "${body}" | jq -c --arg id "${id}" '{action: "rules.update", id: $id, value: .value}' | fw_patch
  else
    printf '%s' "${body}" | fw_patch
  fi
}

# --- Read the active firewall configuration once (for the upsert below) ---
ACTIVE_JSON="$(curl -fsS -H "Authorization: Bearer ${VERCEL_TOKEN}" \
  "https://api.vercel.com/v1/security/firewall/config/active?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}")"

# --- WAF rule: DENY requests carrying x-middleware-subrequest, with a 24h
#     persistent block on the source (Section 3.3) ---
echo "=== Deploying WAF rule to deny x-middleware-subrequest ==="
fw_upsert <<'JSON'
{
  "action": "rules.insert",
  "id": null,
  "value": {
    "name": "hth-cve-2025-29927-deny-middleware-subrequest",
    "description": "Defense in depth for Next.js middleware auth bypass (CVE-2025-29927)",
    "active": true,
    "conditionGroup": [
      { "conditions": [ { "type": "header", "key": "x-middleware-subrequest", "op": "ex" } ] }
    ],
    "action": {
      "mitigate": {
        "action": "deny",
        "actionDuration": "24h"
      }
    }
  }
}
JSON

# --- WAF rule: LOG requests carrying x-nextjs-data / Next-Action (exploit precursors) ---
echo ""
echo "=== Logging suspicious Next.js internal headers ==="
fw_upsert <<'JSON'
{
  "action": "rules.insert",
  "id": null,
  "value": {
    "name": "hth-log-nextjs-internal-headers",
    "description": "Log probes of x-nextjs-data and Next-Action (exploit precursors)",
    "active": true,
    "conditionGroup": [
      { "conditions": [ { "type": "header", "key": "x-nextjs-data", "op": "ex" } ] },
      { "conditions": [ { "type": "header", "key": "next-action", "op": "ex" } ] }
    ],
    "action": {
      "mitigate": { "action": "log" }
    }
  }
}
JSON

# --- Patch gate: installed Next.js must be at or above the guide's LTS floor
#     (16.2.11 Active LTS, 15.5.21 Maintenance LTS) ---
echo ""
echo "=== Next.js patch coverage ==="
if [ -f node_modules/next/package.json ]; then
  NEXT_VERSION="$(jq -r '.version' node_modules/next/package.json)"
  case "${NEXT_VERSION%%.*}" in
    16) FLOOR="16.2.11" ;;
    15) FLOOR="15.5.21" ;;
    *)  echo "BLOCK: next ${NEXT_VERSION} is outside the supported LTS channels (15.5.x, 16.2.x)."; exit 1 ;;
  esac
  if [ "$(printf '%s\n%s\n' "${FLOOR}" "${NEXT_VERSION%%-*}" | sort -V | head -1)" != "${FLOOR}" ]; then
    echo "BLOCK: next ${NEXT_VERSION} is below the ${FLOOR} floor."
    exit 1
  fi
  echo "OK: next ${NEXT_VERSION} >= ${FLOOR}"
else
  echo "UNCHECKED: no node_modules/next here -- run this from the application"
  echo "repository after installing dependencies (exit 2; the WAF rules above were deployed)."
  exit 2
fi

Validation & Testing

  1. package.json pins next to a supported LTS channel at or above every fix in the CVE table (minimum 16.2.11 Active LTS or 15.5.21 Maintenance LTS)
  2. A request with x-middleware-subrequest header from the public internet is denied at the Vercel Firewall
  3. Requests with x-nextjs-data or Next-Action are logged and surface in Firewall observability
  4. Renovate/Dependabot has proposed the latest Next.js patch; its PR is merged within MTTP target
  5. A Next.js security advisory triggered Slack/PagerDuty within an hour of publication

Expected result: The team is positioned to patch Next.js CVEs within 72 hours, with edge-side defense in depth protecting against the highest-impact classes.

Monitoring & Maintenance

  • On pre-announcement (monthly): Read the announced date and anticipated severity on nextjs.org/blog; book the upgrade window and prepare the branch before the release lands
  • On advisory: Review every advisory on nextjs.org/blog; patch critical CVEs within 72 hours
  • Weekly: Review Firewall logs for x-middleware-subrequest / x-nextjs-data / Next-Action probes
  • Monthly: Measure MTTP metric; if trending up, invest in faster builds
  • Quarterly: Review the CVE table against NVD for new entries

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC7.1, CC7.2 System change management, detection of security events
NIST 800-53 SI-2, SI-10, RA-5, SA-11 Flaw remediation, input validation, vulnerability scanning, developer security testing
ISO 27001 A.12.6.1, A.14.2.3 Management of technical vulnerabilities, technical review of applications
PCI DSS 6.2, 6.3.3 Maintain current security patches, bespoke software vulnerability management

10. Customer Misconfiguration Anti-Patterns

These are customer-side misconfigurations, not Vercel platform vulnerabilities. They are well-documented causes of real-world incidents affecting Vercel customers. Each anti-pattern maps to a detection or enforcement control you can add to CI.

10.1 Enforce /_next/image remotePatterns Allowlist

Profile Level: L1 (Crawl)

NIST 800-53: SC-7, SI-10, CM-7

Description

Next.js’s /_next/image endpoint performs server-side fetch() against URLs matching images.remotePatterns in next.config.*. Wildcard or protocol-only patterns enable SSRF — the server can be coerced into fetching internal metadata services, RFC1918 endpoints, or attacker-hosted malicious content.

Rationale

Why This Matters:

  • SSRF via /_next/image was disclosed as CVE-2025-57822 and CVE-2025-6087 — Dominik Prodinger identified 5,000+ potentially affected hosts on the public internet for CVE-2025-57822
  • The vulnerability is a configuration issue (permissive remotePatterns), not a framework bug — patching Next.js alone does not fix it
  • images.domains (deprecated) is wildcard-prone by default; migrating to remotePatterns with explicit pathname restrictions is the safe pattern

Attack Prevented: Full-read or blind SSRF against internal networks, cloud metadata exfiltration (AWS IMDSv1 169.254.169.254), image-optimizer cache poisoning.

ClickOps Implementation

Step 1: Audit next.config.*

  1. Open next.config.js / next.config.mjs / next.config.ts
  2. Locate images.remotePatterns
  3. Remove any entries with hostname: '**', hostname: '*', protocol: '*', or protocol: 'http'
  4. Add explicit pathname restrictions ('/images/**', not '/**')
  5. Delete any images.domains entries (deprecated); migrate to remotePatterns

Step 2: Add the Section-10.1 Lint to CI

  1. Save the pack script (hth-vercel-10.01-next-image-remotepatterns-audit.sh) into scripts/ci/
  2. Add as a required CI step; fail the build on any detected permissive pattern

Step 3: Add an Edge WAF Rule (Defense in Depth)

  1. Create a WAF Custom Rule (Section 3.1 workflow) that denies requests to /_next/image whose decoded url= query parameter resolves to RFC1918, link-local, or loopback ranges
  2. Confirm the rule is in Log mode for 48 hours before switching to Deny to avoid blocking legitimate CDN fetches

Time to Complete: ~20 minutes

Code Implementation

Code Pack: Config
hth-vercel-10.01-next-image-remotepatterns-audit.sh View source on GitHub ↗
CONFIG_FILE=""
for candidate in next.config.js next.config.mjs next.config.ts next.config.cjs; do
  if [ -f "${candidate}" ]; then
    CONFIG_FILE="${candidate}"
    break
  fi
done

if [ -z "${CONFIG_FILE}" ]; then
  echo "No next.config.* detected — skipping /_next/image audit."
  exit 0
fi

echo "=== Auditing ${CONFIG_FILE} remotePatterns, entry by entry ==="

rc=0
node - "${CONFIG_FILE}" <<'JS' || rc=$?
const fs = require('node:fs');
const src = fs.readFileSync(process.argv[2], 'utf8')
  .replace(/\/\*[\s\S]*?\*\//g, '')          // block comments
  .replace(/(^|[^:'"])\/\/.*$/gm, '$1');     // line comments (not inside URLs)
// A finding exits 20, a code node never uses on its own, so a crash (node's
// exit 1) or a here-document bash could not create (bash's exit 1) is never
// read as a finding. The shell below maps 20 back to 1.
const FINDING = 20;
let findings = 0, unread = 0;
const report = (level, msg) => { console.log(`${level}: ${msg}`); findings++; };
const cannot = (msg) => { console.log(`WARN: ${msg} — cannot statically parse; review by hand.`); unread++; };
const near = (i) => src.slice(i, i + 60).replace(/\s+/g, ' ').trim();

// images.domains: deprecated, no protocol/path granularity
if (/\bdomains\s*:\s*\[/.test(src)) {
  report('WARN', 'images.domains is deprecated and wildcard-prone. Migrate to remotePatterns.');
}

// `images` must be an object literal in this file: `images: imported` or a
// spread inside it (`images: { ...base }`) hides remotePatterns from the audit.
for (const m of src.matchAll(/\bimages['"`]?\s*:/g)) {
  const lead = src.slice(m.index + m[0].length).match(/^\s*\{/);
  if (!lead) { cannot(`images is not an object literal (${near(m.index)})`); continue; }
  const props = entriesAt(m.index + m[0].length + lead[0].length - 1);
  if (props && props.some(p => p.startsWith('...'))) cannot(`images spreads another object (${near(m.index)})`);
}

// Bracket-match the array that opens at `open`, then split its top-level entries.
function entriesAt(open) {
  let i = open, depth = 0, quote = null, start = open + 1;
  const out = [];
  for (; i < src.length; i++) {
    const c = src[i];
    if (quote) { if (c === '\\') { i++; } else if (c === quote) { quote = null; } continue; }
    if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
    if (c === '[' || c === '{' || c === '(') depth++;
    if (c === ']' || c === '}' || c === ')') depth--;
    if (depth === 1 && c === ',') { out.push(src.slice(start, i)); start = i + 1; }
    if (depth === 0) { out.push(src.slice(start, i)); return out.map(e => e.trim()).filter(Boolean); }
  }
  return null;
}

// Every mention of remotePatterns must be a literal array: a property
// (`remotePatterns: [`, quoted or not) or a declaration (`remotePatterns = [`,
// with an optional TypeScript type). Anything else -- `remotePatterns: PATTERNS`,
// the `{ remotePatterns }` shorthand, `.concat(...)` -- is reported as unread.
const LITERAL = /^['"`]?\s*(?::\s*[A-Za-z_$][\w$.<>]*(?:\[\])?\s*=|[:=])\s*\[/;
const field = (e, k) => { const m = e.match(new RegExp(`\\b${k}\\s*['"\`]?\\s*:\\s*['"\`]([^'"\`]*)['"\`]`)); return m ? m[1] : null; };
const hasKey = (e, k) => new RegExp(`\\b${k}\\s*['"\`]?\\s*:`).test(e);
// `{ remotePatterns }` shorthand is readable only when this file declares
// `const remotePatterns = [ ... ]` -- that declaration is parsed below.
const declared = /\b(?:const|let|var)\s+remotePatterns\s*(?::[^=;]+)?=\s*\[/.test(src);
let arrays = 0, total = 0;
for (const m of src.matchAll(/\bremotePatterns\b/g)) {
  const after = m.index + 'remotePatterns'.length;
  const lit = src.slice(after).match(LITERAL);
  if (!lit && declared && /^\s*[,}]/.test(src.slice(after))) continue;
  if (!lit) { cannot(`remotePatterns is not a literal array (${near(m.index)})`); continue; }
  const list = entriesAt(after + lit[0].length - 1);
  if (!list) { console.error('ERROR: could not parse the remotePatterns array.'); process.exit(2); }
  const name = arrays === 0 ? 'remotePatterns' : `remotePatterns#${arrays + 1}`;
  arrays++; total += list.length;
  list.forEach((e, n) => {
    const label = `${name}[${n}]`;
    let protocol, hostname, pathname;
    const url = e.match(/^new\s+URL\(\s*['"`]([^'"`]+)['"`]/);
    if (url) {
      const u = url[1].match(/^([a-z*]+):\/\/([^/]+)(\/.*)?$/i);
      if (!u) { report('WARN', `${label} unparseable URL pattern ${url[1]}`); return; }
      [protocol, hostname, pathname] = [u[1], u[2], u[3] && u[3] !== '/**' ? u[3] : null];
    } else if (e.startsWith('{')) {
      protocol = field(e, 'protocol'); hostname = field(e, 'hostname'); pathname = field(e, 'pathname');
      if (!hostname && hasKey(e, 'hostname')) { cannot(`${label} hostname is not a string literal`); return; }
    } else {
      cannot(`${label} is not a literal object or new URL(...): ${e.slice(0, 80)}`);
      return;
    }
    if (!hostname || hostname === '*' || hostname === '**') report('BLOCK', `${label} bare hostname wildcard ('${hostname}')`);
    else if (hostname.startsWith('*')) report('WARN', `${label} wildcard subdomain '${hostname}' — any tenant of that domain can serve images`);
    if (protocol === '*') report('BLOCK', `${label} wildcard protocol`);
    else if (protocol === 'http') report('WARN', `${label} http:// protocol — prefer https:// only`);
    if (!pathname) report('WARN', `${label} (${hostname}) has no pathname restriction — any path is allowed`);
  });
}

if (unread) process.exit(2);
if (arrays === 0) {
  if (!findings) console.log('OK: no remotePatterns declared (remote images disabled).');
  process.exit(findings ? FINDING : 0);
}
if (!findings) console.log(`OK: ${total} remotePatterns entr${total === 1 ? 'y' : 'ies'}, all restrictive.`);
process.exit(findings ? FINDING : 0);
JS

case "${rc}" in
  0) ;;
  20|2)
    if [ "${rc}" -eq 20 ]; then rc=1; fi
    echo ""
    echo "Recommended shape (a literal array; hostname and pathname on EVERY entry):"
    echo "  { protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**' }"
    ;;
  *)
    echo "ERROR: the audit did not run to a verdict (exit ${rc}: node missing or crashed, or no temp file for the here-document); nothing was checked (exit 2)." >&2
    rc=2
    ;;
esac
exit "${rc}"

Validation & Testing

  1. Lint script exits non-zero against a synthetic permissive config (hostname: '**')
  2. Production next.config.* has no wildcard hostnames or protocols
  3. WAF rule blocks a test request: /_next/image?url=http://169.254.169.254/ (from the public internet)
  4. Legitimate image fetches from allowlisted CDNs continue to work

Expected result: /_next/image only fetches from explicit (protocol, hostname, pathname) tuples; SSRF against internal endpoints is blocked at two layers.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6, CC7.1 External threat protection, change management
NIST 800-53 SC-7, SI-10, CM-7 Boundary protection, input validation, least functionality
ISO 27001 A.14.2.1, A.13.1.1 Secure development policy, network controls
PCI DSS 6.3, 1.3 Secure development, prohibit direct public access

10.2 Enforce Authorization Defense in Depth (No Middleware-Only Authz)

Profile Level: L1 (Crawl)

NIST 800-53: AC-3, SI-10

Description

CVE-2025-29927 proved that Next.js middleware can be bypassed from the public internet via a spoofed internal header. Any authorization logic that lives only in middleware is therefore bypassable, even after patching. Enforce authorization a second time inside Route Handlers, Server Components, and Server Actions for every protected endpoint.

Rationale

Why This Matters:

  • Every credible external researcher (zhero_web_security, Assetnote, Datadog Security Labs, Praetorian) converges on this conclusion: middleware is not a security boundary
  • Defense in depth means a single-layer bypass does not compromise the application
  • The pattern is cheap to apply (a few lines per handler) and immune to the next framework-level bypass CVE

Attack Prevented: Authorization bypass via middleware-only enforcement, including both CVE-2025-29927-style header smuggling and any future analogous middleware-skip vulnerability.

ClickOps Implementation

Step 1: Inventory Middleware-Gated Paths

  1. Locate middleware.ts (or middleware.js) — on Next.js 16 the same file convention is named proxy.ts (Next.js: Proxy), and it is the same non-boundary
  2. Extract every path matched by config.matcher
  3. For each path, locate the Route Handler, Server Component, or Server Action implementation

Step 2: Add In-Handler Authorization

  1. For every Route Handler (app/**/route.ts), add an explicit await getSession() / await getUser() check at the top of the handler
  2. For every Server Component that renders protected data, repeat the session check inside the component
  3. For every Server Action ("use server" file), repeat the session check inside the action function — the Next-Action header alone is not sufficient authorization

Step 3: Add the Section-10.2 Lint to CI

  1. Save the pack script (hth-vercel-10.02-middleware-authz-defense-in-depth.sh) into scripts/ci/
  2. CI runs the script on every PR; script flags Route Handlers / Server Actions that lack an apparent in-handler authorization check
  3. Treat warnings as blocking for paths covered by the middleware.ts / proxy.ts matcher

Step 4: Document the Pattern in Code-Review Checklist

  1. Add a line item to your PR review template: “Does every protected endpoint authorize inside the handler, not only in middleware?”
  2. Include in engineering onboarding materials

Time to Complete: ~1 hour per protected path cluster (initial) + ongoing

Code Implementation

Code Pack: Config
hth-vercel-10.02-middleware-authz-defense-in-depth.sh View source on GitHub ↗
ROOT="${1:-.}"
EXIT_CODE=0

echo "=== Scanning for middleware-only authorization patterns in ${ROOT} ==="

# 1. Locate the middleware / proxy file
MIDDLEWARE=""
for base in middleware proxy; do
  for candidate in "${ROOT}/${base}.ts" "${ROOT}/${base}.js" \
                   "${ROOT}/src/${base}.ts" "${ROOT}/src/${base}.js"; do
    if [ -f "${candidate}" ]; then
      MIDDLEWARE="${candidate}"
      break 2
    fi
  done
done

if [ -z "${MIDDLEWARE}" ]; then
  echo "(no middleware.* or proxy.* file found — no middleware-only risk to flag)"
  exit 0
fi

echo "Found: ${MIDDLEWARE}"

# 2. Does it reference auth/session/token checks?
if ! grep -qiE "(auth|session|token|cookie|jwt|role|permission)" "${MIDDLEWARE}"; then
  echo "OK: ${MIDDLEWARE} does not appear to perform authorization."
  exit 0
fi

echo "NOTE: ${MIDDLEWARE} appears to gate auth. Verifying route-level defense in depth..."

# 3. Matched paths: array form  matcher: ['/a/:path*', '/b']  or string form  matcher: '/a/:path*'
MATCHER_PATHS="$( { grep -oE "matcher:[[:space:]]*\[[^]]+\]" "${MIDDLEWARE}" || true; } | \
  tr -d "'\"[]" | sed 's/^matcher:[[:space:]]*//' | tr ',' '\n' | awk 'NF {print $1}')"
if [ -z "${MATCHER_PATHS}" ]; then
  MATCHER_PATHS="$( { grep -oE "matcher:[[:space:]]*['\"][^'\"]+['\"]" "${MIDDLEWARE}" || true; } | \
    sed -E "s/^matcher:[[:space:]]*['\"]//; s/['\"]$//")"
fi
if [ -z "${MATCHER_PATHS}" ]; then
  echo "WARN: cannot detect matcher paths — cannot verify coverage."
  EXIT_CODE=1
else
  echo "Matcher paths:"
  printf '%s\n' "${MATCHER_PATHS}" | sed 's/^/  /'
fi

# 4. Every Route Handler under app/ must also check auth itself
if [ -d "${ROOT}/app" ] || [ -d "${ROOT}/src/app" ]; then
  APP_DIR="${ROOT}/app"
  [ -d "${ROOT}/src/app" ] && APP_DIR="${ROOT}/src/app"

  # One path per line, read verbatim: an unquoted $(find) list would split
  # "app/my reports/route.ts" at the space and treat a dynamic segment such as
  # app/users/[id]/route.ts as a glob (matching app/users/d/route.ts instead).
  HANDLERS="$(find "${APP_DIR}" -type f \( -name 'route.ts' -o -name 'route.js' \))"
  while IFS= read -r handler; do
    [ -n "${handler}" ] || continue
    if ! grep -qiE "(auth|session|getServerSession|getUser|token|cookie|unauthorized|redirect)" "${handler}"; then
      echo "WARN: ${handler} has no apparent in-handler authorization check."
      EXIT_CODE=1
    fi
  done < <(printf '%s\n' "${HANDLERS}")
fi

# 5. Server Actions ('use server' / "use server") that lack auth checks.
#    grep is the fallback when rg is absent -- the scan never silently skips.
USE_SERVER_RE="[\"']use server[\"']"
rc=0
if command -v rg >/dev/null 2>&1; then
  ACTION_FILES="$(rg -l --glob '!node_modules/**' --glob '!.next/**' -e "${USE_SERVER_RE}" "${ROOT}")" || rc=$?
else
  ACTION_FILES="$(grep -rlE --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git "${USE_SERVER_RE}" "${ROOT}")" || rc=$?
fi
if [ "${rc}" -gt 1 ]; then
  echo "ERROR: Server Action scan failed (exit ${rc})." >&2
  exit 2
fi
while IFS= read -r action_file; do
  [ -n "${action_file}" ] || continue
  if ! grep -qiE "(auth|session|getServerSession|getUser|unauthorized|throw)" "${action_file}"; then
    echo "WARN: Server Action file ${action_file} lacks authorization check."
    EXIT_CODE=1
  fi
done < <(printf '%s\n' "${ACTION_FILES}")

if [ "${EXIT_CODE}" -eq 0 ]; then
  echo "OK: route-level defense in depth appears present."
else
  echo ""
  echo "Per CVE-2025-29927, middleware CAN be bypassed. Enforce authz a second"
  echo "time inside Route Handlers, Server Components, and Server Actions."
fi

exit "${EXIT_CODE}"

Validation & Testing

  1. A simulated x-middleware-subrequest probe against a protected Route Handler is rejected by the handler even when middleware is skipped (reproduce in a local test by mocking middleware-skip)
  2. Lint script reports zero in-handler warnings for protected paths
  3. Code-review checklist is enforced

Expected result: Authorization is enforced at every protected boundary. A middleware bypass does not become an application-authorization bypass.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.3 Logical access controls, multi-layer controls
NIST 800-53 AC-3, SI-10, SC-3 Access enforcement, input validation, security function isolation
ISO 27001 A.9.4.1, A.14.2.5 Information access restriction, secure engineering principles
PCI DSS 7.2, 6.5 Restrict access by job function, secure coding practices

10.3 Do Not Stack a Reverse Proxy in Front of Vercel Bot Protection

Profile Level: L1 (Crawl)

NIST 800-53: SC-7, SI-4

Description

Placing a reverse proxy (Cloudflare, Azure Front Door, AWS CloudFront) in front of Vercel breaks Bot Protection. Vercel’s Bot Protection Managed Ruleset relies on JA3/JA4 TLS fingerprints and client IP stability — both of which are masked or rotated by upstream proxies. Either use Vercel Firewall directly, or disable Vercel Bot Protection and rely exclusively on the front proxy’s WAF — but do not run both expecting additive protection.

Rationale

Why This Matters:

  • Per Vercel Bot Management docs: “Reverse proxies interfere with Vercel’s ability to reliably identify bots… obscured detection signals… frequent re-challenges”
  • Teams that layer Cloudflare in front of Vercel frequently experience mysterious legitimate-user blocks and still-present bot traffic — the stack is worse than either tier alone
  • The Reverse Proxy detection and challenges-per-IP-change behavior can be tuned in the front WAF instead, providing a single coherent policy

Attack Prevented: False negatives in bot classification, false positives blocking legitimate users, operational complexity that masks real security events.

ClickOps Implementation

Step 1: Detect Current Topology

  1. dig <your-domain> — if the CNAME resolves to Cloudflare / CloudFront / Azure Front Door before Vercel’s edge, you are proxied
  2. Confirm via curl -I https://<your-domain>/ — check for upstream-proxy-specific headers (cf-ray, x-amz-cf-id, etc.)

Step 2: Make the Architectural Decision

  • Option A: Use Vercel Firewall directly. Remove the upstream proxy; point DNS directly to Vercel. Benefit: JA3/JA4-based Bot Protection works correctly. Downside: dedicated perimeter WAF (Cloudflare, Akamai) is no longer in the path.
  • Option B: Use the upstream proxy’s WAF exclusively. Keep the upstream proxy; disable Vercel’s Bot Protection Managed Ruleset; move bot and managed-rule policy to the upstream. Benefit: single coherent WAF policy. Downside: Vercel’s $1M-bounty-hardened bot rules are no longer engaged.

Step 3: Document the Choice

  1. Record the decision and the rationale in the team’s architecture documentation
  2. Ensure on-call runbooks reflect the chosen topology (e.g., “for bot-related incidents, investigate in [Cloudflare Vercel] first”)

Step 4: Monitor After the Change

  1. Track Bot Protection false-positive rate for 14 days after any topology change
  2. Tune challenge actions using the WAF in use — not the other one

Time to Complete: ~30 minutes (decision) + application-specific migration time

Automation: ClickOps only — Vercel exposes no write interface for this setting: whether a reverse proxy sits in front of the deployment is a DNS and architecture decision, not a Vercel setting (Bot Management: reverse proxies, 2026-09-24). Option B’s one Vercel-side change, turning off the Bot Protection managed ruleset, is automatable with the Section 3.4 pack.

Validation & Testing

  1. Either Vercel Bot Protection is enabled AND DNS points directly to Vercel (no upstream proxy), OR Vercel Bot Protection is disabled AND the upstream WAF handles bot classification
  2. False-positive rate measured and acceptable for 14 days post-change

Expected result: Bot classification is reliable and operational responsibility is unambiguous.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.6 External threat protection
NIST 800-53 SC-7, SI-4 Boundary protection, system monitoring
ISO 27001 A.13.1.1, A.13.1.2 Network controls, security of network services
PCI DSS 11.4, 1.3 Intrusion detection/prevention, network segmentation

10.4 Treat Container Registry Public Access as a Change-Controlled Action

Profile Level: L1 (Crawl)

NIST 800-53: AC-3, AC-21, CM-3, SA-12

Description

Since 2026-08-07, a Vercel Container Registry repository can be flipped from private to public (changelog). Repositories are private by default, and making one public grants read-only pull access to every Vercel account holder — an expansion from the previous ceiling of up to 100 explicitly named teams to the entire platform user base. Push and delete remain protected. Because image layers are trivially extractable once pullable, publishing a repository is an irreversible disclosure of everything baked into those layers.

Rationale

Why This Matters:

  • The audience change is categorical, not incremental: “up to 100 named teams” and “anyone with a Vercel account” are different security postures, and the toggle that moves between them is a single dashboard confirmation
  • Container layers routinely embed build-time secrets, internal registry credentials, private package tokens, and proprietary source that survive in intermediate layers even when deleted in a later RUN step
  • Public access is a sharing feature, so it is easy to enable for a legitimate reason (a public sample image, an OSS artifact) and never re-review — exactly the drift pattern the Section 1.5 audit exists to catch

Attack Prevented: Inadvertent disclosure of proprietary source and build artifacts, harvesting of build-time secrets and registry credentials from image layers, supply-chain reconnaissance against your internal build tooling.

Prerequisites

  • Vercel Container Registry in use for the project
  • Project dashboard access, or the Vercel CLI authenticated to the team
  • A defined approval path for publishing artifacts externally

ClickOps Implementation

Step 1: Inventory Repository Visibility

  1. Navigate to: Project dashboard → Images
  2. For each repository, open Settings and record whether Public Access is enabled
  3. Treat any repository whose public status nobody can explain as an incident until proven intentional

Step 2: Gate the Toggle Behind Change Control

  1. Enabling public access requires typing the repository name to confirm — treat that confirmation as the last step, not the approval
  2. Require a documented approval (security review of the image contents plus a named business owner) before anyone reaches the toggle
  3. The CLI path is equivalent and equally consequential: vercel vcr config <repository> --public true — cover it in the same policy, and keep it out of unattended automation

Step 3: Verify the Image Before Publishing

  1. Pull the exact tag and inspect every layer for secrets, internal registry credentials, private package tokens, and proprietary source
  2. Rebuild from a clean, secret-free Dockerfile rather than attempting to scrub an existing image — deleted files persist in earlier layers
  3. Confirm the base image and any vendored dependencies are ones you are licensed to redistribute

Step 4: Fold Into the Quarterly Audit

  1. Add registry visibility to the Section 1.5 third-party/integration audit checklist
  2. Re-confirm each public repository still has a business owner and still needs to be public
  3. Revert to private the moment the justification lapses — and rotate anything that was ever embedded in a published layer

Time to Complete: ~30 minutes (initial inventory) + quarterly review

Code Implementation

Code Pack: Terraform
hth-vercel-10.04-vcr-repository-private.tf View source on GitHub ↗
# --- L1: Keep every Vercel Container Registry repository PRIVATE. Making one
#     public becomes a reviewed change to this file, not a dashboard toggle.
#     Import existing repositories before the first apply:
#       terraform import 'vercel_vcr_repository.private["<name>"]' <team_id>/<project_id>/<name>
resource "vercel_vcr_repository" "private" {
  for_each = var.vcr_repositories

  project_id = var.project_id
  team_id    = var.vercel_team_id
  name       = each.value
  public     = false
}

Validation & Testing

  1. Every Container Registry repository’s visibility is recorded, with a named owner for each public one
  2. No repository was made public without a documented approval
  3. A layer inspection of each public image finds no secrets, credentials, or proprietary source
  4. Push and delete operations from an unauthorized account are rejected (only pull is public)
  5. Registry visibility appears on the quarterly Section 1.5 audit checklist

Expected result: Repository visibility is an explicitly approved, periodically re-justified decision — never an incidental one.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1, CC6.6, CC8.1 Logical access controls, external threat protection, change management
NIST 800-53 AC-3, AC-21, CM-3, SA-12 Access enforcement, information sharing, configuration change control, supply chain protection
ISO 27001 A.9.4.1, A.13.2.1, A.12.1.2 Information access restriction, information transfer policies, change management
PCI DSS 6.4.1, 7.2, 3.1 Separate dev/test from production, restrict access by job function, data retention and disposal

Appendix A: Edition Compatibility

Control Section Hobby Pro Enterprise
SAML SSO 1.1 ❌ Add-on ✅
Directory Sync (SCIM) 1.2 ❌ ❌ ✅
RBAC (full roles) 1.3 Basic Extended Full
Access Groups 1.3 ❌ Limited ✅
Security Role 1.3 ❌ ❌ ✅
OIDC Federation 1.4 ✅ ✅ ✅
Deployment Protection (Standard) 2.1 ✅ ✅ ✅
Password Protection 2.1 ❌ $20/mo per protected project ✅
Trusted IPs 2.1 ❌ ❌ ✅
Passport (IdP-backed deployment access) 2.1 ❌ ❌ ✅ (account-team pricing)
Protected Source Maps 2.5 ✅ ✅ ✅
Git Fork Protection 2.2 ✅ ✅ ✅
Rolling Releases 2.3 ❌ ✅ ✅
WAF Custom Rules 3.1 3 rules 40 rules Up to 1,000 rules
WAF Managed Rulesets 3.1 ❌ ❌ ✅
JA3 (Legacy) rule parameter 3.2 ❌ ❌ ✅
IP Blocking (project) 3.2 Up to 3 Up to 100 Up to 1,000
IP Blocking (account) 3.2 ❌ ❌ Custom (max /16 IPv4, /48 IPv6)
Rate Limiting 3.2 ❌ ✅ ✅
Vercel BotID — Basic 3.5 ✅ ✅ ✅
Vercel BotID — Deep Analysis 3.5 ❌ $1 / 1,000 calls Custom
Secure Compute 4.1 ❌ ❌ ✅ ($6.5K/yr)
VPC Peering 4.1 ❌ ❌ ✅
DDoS Mitigation 4.2 ✅ ✅ ✅ + dedicated
Attack Challenge Mode 4.2 ✅ ✅ ✅
Spend Management 4.2 ❌ ✅ ✅
Custom Security Headers 5.1 ✅ ✅ ✅
Sensitive Env Var Policy 6.1 ❌ ✅ ✅
Deployment Retention 6.2 ✅ ✅ ✅
Third-Party Integration Audit 1.5 ✅ ✅ ✅
Private Production Deployments 2.4 ✅ (Vercel Authentication) ✅ (+ Password Protection at $20/mo per project) ✅
Firewall Persistent Actions 3.3 ❌ ✅ ✅
AI Bots Managed Ruleset 3.4 ❌ ❌ ✅
Rotate Deploy Hooks 6.3 ✅ ✅ ✅
Block NEXT_PUBLIC_ Secret Leaks (lint) 6.4 ✅ ✅ ✅
Drain Signature Verification 8.4 ❌ ✅ ✅
Next.js CVE Management 9.1 ✅ ✅ ✅
/_next/image remotePatterns Audit 10.1 ✅ ✅ ✅
Authorization Defense in Depth 10.2 ✅ ✅ ✅
Reverse-Proxy + Vercel Bot Protection (do not stack) 10.3 ✅ ✅ ✅
Container Registry Public Access (change-controlled) 10.4 Where used Where used Where used
Drains 8.1 ❌ ✅ ✅
Audit Logs 8.2 ❌ ❌ ✅ (90 days)
Audit Log Drains (replaces Custom SIEM Log Streaming) 8.2 ❌ ❌ ✅

Appendix B: References

Official Vercel Documentation:

CLI & API Documentation:

Compliance Frameworks:

  • SOC 2 Type II (Security, Confidentiality, Availability)
  • ISO 27001:2022
  • PCI DSS v4.0 (SAQ-D AOC for Service Providers, SAQ-A AOC for Merchants)
  • HIPAA BAA (Enterprise)
  • EU-U.S. Data Privacy Framework
  • TISAX Assessment Level 2

Security Incidents (Platform):

  • 2026 — Vercel Platform Supply-Chain Incident (April 2026): Lumma Stealer infection at Context.ai compromised Google Workspace OAuth tokens. Attacker hijacked a Vercel employee’s Workspace account and enumerated customer non-sensitive environment variables. Sensitive-flagged variables were not affected. Customers with no direct relationship to Context.ai were impacted. See Vercel KB Bulletin, Trend Micro analysis, Appendix C.

Next.js Security Release Program:

Platform Changelog (deprecations and new surfaces):

Security Incidents (Framework — Next.js, maintained by Vercel):

  • 2026-07 — CVE-2026-64641 … CVE-2026-64649 (July 2026 security release): Nine advisories including an App Router Server Actions CPU-exhaustion DoS, a middleware/proxy bypass on Turbopack with a single config.i18n.locales entry (same outcome class as CVE-2025-29927), two SSRF classes (rewrites()/redirects() destination construction and Server Actions Host-header handling on custom servers), Server Action endpoint-ID disclosure, /_next/image SVG DoS, unbounded Edge-runtime Server Action payloads, and server-side fetch cache confusion. Fix: 16.2.11 / 15.5.21. Next.js advisory.
  • 2026-04 — CVE-2026-23869 (DoS via unsafe RSC deserialization): Affects Next.js 13.x–16.x App Router. Fix: 15.5.15 / 16.2.3. Vercel changelog.
  • 2025-12 — CVE-2025-55182 / 66478 (“React2Shell”, CVSS 10.0): Critical unsafe deserialization in React Server Components enabling unauthenticated RCE. Active in-the-wild exploitation observed by Trend Micro. Fix: Next.js 15.5.7 / 16.0.7. $1M Vercel bounty surfaced 20 WAF bypasses, confirming framework patching is mandatory. Praetorian advisory, Next.js Advisory, Vercel $1M bounty blog.
  • 2025-12 — CVE-2025-55184 (RSC DoS): Bundled with React2Shell. Fix: same.
  • 2025-12 — CVE-2025-55183 (RSC source code disclosure): Bundled with React2Shell. Fix: same.
  • 2025-06 — CVE-2025-49826 (204 response cache poisoning DoS, CVSS 7.5): Fix: Next.js 15.1.8. GHSA-67rr-84xm-4c7r.
  • 2025-03 — CVE-2025-29927 (Middleware authorization bypass, CVSS 9.1): Spoofed x-middleware-subrequest bypasses all middleware-enforced checks. Mass scanning within 48 hours. Vercel WAF stripped the header at edge before disclosure. Discovered by zhero_web_security + yvvdwf. Fix: 12.3.5 / 13.5.9 / 14.2.25 / 15.2.3.
  • 2024-09 — CVE-2024-46982 (Pages Router cache poisoning, CVSS 7.5): Fix: 13.5.7 / 14.2.10. GHSA.
  • 2024-04 — CVE-2024-34351 (Server Actions SSRF): Host-header manipulation in self-hosted Next.js. Vercel-hosted not exploitable in standard configuration. Assetnote research. Fix: Next.js 14.1.1.

Security Researcher Primary Sources:

Industry Commentary (Contrarian Voices):

Community Security Research:


Appendix C: April 2026 Incident Response Playbook

This playbook is applicable to any Vercel customer whose projects existed prior to April 19, 2026. It is derived from Vercel’s KB bulletin and community-maintained IR materials. Execute in order; most items can be completed within a single working day.

C.1 Immediate Triage (First 24 Hours)

  1. Enable team MFA enforcement for all members. Require authenticator apps or passkeys; disable SMS as a second factor.
  2. Audit account activity logs. Open the team’s Activity Log (dashboard → Activity, or vercel activity from the CLI); Enterprise owners can export the fuller record from Team Settings → Security & Privacy → Audit Log. Review all logins, token creations, and deployment actions for the 60 days prior to 2026-04-19. Flag anything unexpected.
  3. Enumerate all environment variables via the Vercel dashboard or API: GET /v10/projects/{id}/env. List each variable’s project, environment, and whether it is marked Sensitive.
  4. Any variable NOT marked Sensitive is considered exposed. Rotate the underlying credential at its source system immediately — database passwords, API keys, signing keys, webhook secrets — regardless of whether Vercel notified you directly.
  5. Recreate all rotated secrets in Vercel with the Sensitive flag enabled. Use Section 6.1’s guidance; do not rely on the old un-sensitive entries.
  6. Revoke all Vercel API tokens and regenerate the minimum set needed. Limit expiry to ≤90 days.

C.2 Google Workspace / OAuth Audit (Within 48 Hours)

  1. admin.google.com → Security → API Controls → Third-party app access.
  2. Search for OAuth app ID 110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com — the Vercel-documented IOC. Revoke if present in any user’s granted apps.
  3. Review all unrecognized third-party apps and any Drive-permissioned apps that are not business-critical; revoke aggressively, re-grant only on demand.
  4. Repeat for GitHub Organization OAuth apps and GitHub Apps (restrict Vercel GitHub App scope per Section 6.3).
  5. Repeat for Microsoft Entra Enterprise Applications and Slack Installed Apps.

C.3 Deployment and Code Investigation

  1. List all deployments for the period 2026-04-01 → now. Any deployment initiated by an unusual actor, from an unusual IP, or at an unusual time is a candidate for forensic review.
  2. Check Git provider audit logs (GitHub Audit Log, GitLab Audit Events) for suspicious deploy-hook invocations, webhook installs, or GitHub App permission changes.
  3. Rotate all deploy hooks per Section 6.3. Treat existing hook URLs as burned.
  4. Scan the git history of every repo connected to Vercel for leaked deploy hook URLs, long-lived API tokens, or API keys. Rotate anything found and rewrite history.

C.4 Platform Hardening Follow-Through

  1. Enable Enforce Sensitive Environment Variables (Section 6.1, Step 1). Make this the permanent baseline.
  2. Enable Deployment Protection (Section 2.1) at Standard minimum across every project; regenerate any Deployment Protection automation bypass tokens.
  3. Install the Section 6.4 lint in CI to prevent NEXT_PUBLIC_ secret regressions.
  4. Configure Drains (Section 8.1 + 8.4) to forward all logs to a SIEM with signature verification. Without off-platform logs, forensic evidence is lost after Vercel’s short-term retention window.
  5. Subscribe to Vercel KB Bulletin for future incidents and to Next.js security advisories (Section 9.1).

C.5 Long-Term Program Changes

  1. Build a quarterly third-party OAuth audit into your control calendar (Section 1.5). Vendor→vendor OAuth trust is now a documented supply-chain vector.
  2. Move cloud-provider authentication from long-lived keys to OIDC Federation (Section 1.4). Static credentials were the vector in this incident; eliminating them eliminates the class of attack.
  3. Measure MTTP (mean time to patch) for Next.js CVEs per Section 9.1. Target ≤72 hours for critical.
  4. Add an annual red-team exercise focused on supply-chain OAuth trust chains — verify that a compromised vendor OAuth could not pivot into your own Vercel/Google/GitHub environments undetected.

C.6 Communication and Documentation

  1. If your application stores end-user data, assess whether the incident is reportable under GDPR (72-hour notification), HIPAA Breach Notification, or any contractual customer obligations.
  2. Publish an internal postmortem referencing this playbook. Future responders need to know what was done.
  3. Update the team runbook and onboarding materials with the “mark everything Sensitive” rule so new hires inherit the post-incident baseline.

Changelog

Date Version Maturity Changes Author
2025-12-14 0.1.0 ai-drafted Initial Vercel hardening guide Claude Code (Opus 4.5)
2026-02-24 1.0.0 ai-drafted [SECURITY] Complete guide revamp: expanded from 4 to 8 sections covering WAF, network security, security headers, domain security; added 20 controls with ClickOps and code pack references; integrated Vercel Shared Responsibility Model, production checklist, Terraform provider v4.6, CLI docs, and API docs; added comprehensive compliance mappings; updated edition compatibility matrix; incorporated security researcher findings and CVE references Claude Code (Opus 4.6)
2026-04-24 1.1.0 ai-drafted [SECURITY] Post-April-2026-incident integration: added Section 1.5 (Third-Party Integration Audit), 2.4 (Private Production Deployments / Advanced DP), 3.3 (Firewall Persistent Actions), 3.4 (AI Bots Managed Ruleset), 6.3 (Rotate Deploy Hooks), 6.4 (Block NEXT_PUBLIC_ Secret Leaks), 8.4 (Drain Signature Verification); added new top-level Section 9 (Framework CVE Management — Next.js) and Section 10 (Customer Misconfiguration Anti-Patterns) including middleware authz defense in depth, /_next/image remotePatterns audit, reverse-proxy + Bot Protection stacking guidance; added Appendix C April 2026 Incident Response Playbook. Updated Section 2.1 Deployment Protection with methods × scopes matrix, Routing Middleware coverage, full Protection Bypass for Automation details, and team-default settings. Updated Section 2.3 Rolling Releases with Skew Protection requirement and 0%-canary security caveat. Updated Section 3.1 WAF with JA3/JA4 fingerprinting, reverse-proxy incompatibility, vercel.json custom-rules limitations, and $1M bounty context. Updated Section 4.1 Secure Compute with Edge Runtime not-supported caveat, VPC peering limit, and active/passive failover. Updated Section 4.2 Attack Challenge Mode with internal-request per-account boundary and standalone-API caveat. Updated Section 6.1 Environment Variables: elevated Enforce Sensitive Environment Variables to L1 baseline; added April 2026 incident rationale; documented sensitive-not-supported-in-development gap. Updated Section 8.1 Drains: rebranded from Log Drains; documented four schema types; added IP Address Visibility toggle. 10 new pack files: hth-vercel-1.05, 2.04, 3.03, 3.04, 6.03, 6.04, 8.04, 9.01, 10.01, 10.02. Added private_production_deployments_enabled and production_only_trusted_ips_enabled to variables.tf. Claude Code (Opus 4.7)
2026-08-08 1.2.0 ai-drafted [SECURITY] Currency pass: added Section 2.5 (Protected Source Maps — default-on for new projects, opt-in for existing), 3.5 (Vercel BotID Basic/Deep Analysis), and 10.4 (Container Registry public repositories as a change-controlled action). Added Passport as the fourth Deployment Protection method in 2.1 with the bypass-secret ordering caveat, and its passport-access-granted detection event in 8.2. Rewrote 8.2 for Audit Log Drains — Custom SIEM Log Streaming deprecated 2026-08-07, destinations now S3/Splunk/Datadog/Panther/custom HTTPS, drain signature verification (8.4) now applies to audit logs. Updated 9.1 for the Next.js preannounced monthly security-release program and LTS channels (minimum 16.2.11 Active LTS / 15.5.21 Maintenance LTS, up from 15.5.15 / 16.2.3), reframed MTTP to start at pre-announcement, and added the nine July 2026 CVEs (CVE-2026-64641 through CVE-2026-64649) including the CVE-2026-64642 middleware bypass corroborating 10.2. Corrected 3.2 IP blocking limits (project Hobby 3 / Pro 100 / Enterprise 1,000; account-level Enterprise-only with /16 IPv4 and /48 IPv6 CIDR ceilings) and noted JA3 (Legacy) as Enterprise-only. Documented the 32-character build-log redaction floor for sensitive environment variables in 6.1. Updated Appendix A and the moved Vercel WAF docs URL in Appendix B. Claude Code (Opus 4.8)
2026-08-08 1.2.1 ai-drafted Add Code Pack for 3.5 Vercel BotID (hth-vercel-3.05, sdk type): withBotId() next.config wrap, initBotId() client route declarations, and checkBotId() server-side handler gate, all fetch-verified against vercel.com/docs/botid/get-started; wired the 3.5 pack include Claude Code (Fable 5)
2026-09-25 1.3.0 ai-drafted [SECURITY] Offline fix loop of a validate-hth-guide run (Vercel console signed out, so 0 surfaces were exercised live and no ai-validated status is claimed). Console paths corrected against current Vercel docs: 1.1 and 1.2 (Security & Privacy → Authentication and User Provisioning → Configure), 1.4 OIDC (a project setting: Project Settings → Security), 1.5 deploy hooks (Project Settings → Git), 4.1 (Team Settings → Networking; projects attach under Project Settings → Networking), 8.2 (Security & Privacy → Audit Log), Appendix C (Activity Log). 3.3 now describes persistence as the rule’s for timeframe (actionDuration); persistentAction is not a real field. 8.2 alert names now use the documented Activity Log event names. 8.4 uses POST /v1/drains/test. 10.2 covers Next.js 16 proxy.ts. The 6.3 leak-search pattern now matches the documented hook URL shape (/v1/integrations/deploy/). Terraform: provider ~> 2.0 → ~> 5.17 (validated on 5.17.1, Terraform ≥ 1.6), since the 2.x module failed terraform validate. There is now one adopted vercel_project (import block, new project_name variable), one vercel_firewall_config, and one vercel_team_config, with the other controls feeding them through locals. Adopting the project changes only the hardening settings the pack declares: its Git link, framework, build commands and other settings are left as they are (lifecycle.ignore_changes; without it the import planned them to null, and a null git_repository unlinks the repository). A precondition stops the apply rather than remove Password Protection or Trusted IPs, narrow All Deployments protection, or rename the project, and below L2 the current skew protection, disabled previews and verified commits are kept. L1 now uses the current Standard Protection (standard_protection_new), not the Legacy scope. The firewall config is created only when the firewall is managed, and replacing an existing one needs firewall_replace_existing_config = true because the provider PUTs the whole config; Attack Challenge Mode is managed only while enabled. At the defaults the pack therefore no longer switches off an existing firewall or Attack Challenge Mode. The nonexistent vercel_network_project_link is removed. Added vercel_network.cidr, attack_mode_active_until, and vercel_project_deployment_retention; create_deployments is a bool in 5.x. New packs: 2.5 (protected_sourcemaps) and 10.4 (vercel_vcr_repository, private). 10.3 now states its automation verdict. API/CLI/config packs: firewall writes use PATCH rather than the full-config PUT, and managedRules replaces managedRulesets. Every read uses curl -f, so an auth failure aborts instead of printing empty findings. Fail-open audits fixed in 1.2 (every page of team members and access groups is read; a walk that cannot finish exits 2 rather than under-reporting owners), 1.4 (OIDC from the project; the token list is requested with limit=100 and a second page exits 2 instead of being silently skipped), 1.5 (array response, deploy hooks from link.deployHooks, ssoProtection, and every page of /v10/projects; a walk that cannot finish exits 2 instead of auditing only the first 100 projects), 2.3, 6.4 (hidden and ignored files; a .next bundle scan that cannot read a file exits 2), 7.1 (JSON on stdout, the team --scope, and exit 2 when dig is missing or a lookup fails; a probe that cannot connect is now a finding, and zone names removed from Vercel can be passed as arguments), 10.1 (per-entry check, quoted keys, and exit 2 when remotePatterns or images comes from a variable, an import or a spread it cannot read), and 10.2 (string matcher, missing-rg fallback, and file paths with spaces or [id]-style segments are read verbatim instead of being split or glob-expanded). 7.2 now tests Validation 2 (TLS 1.0 and 1.1 must be refused; verdicts are read from the handshake transcript so LibreSSL and OpenSSL 3 clients both work), requires HSTS max-age of at least one year (plus includeSubDomains and preload with HTH_HSTS_PRELOAD=1), lists the team’s certificates with --scope across every page, and exits 0, 1 or 2 for clean, finding or unchecked. 3.3 and 9.1 update an existing hth-* rule in place (rules.update) instead of inserting a duplicate on every re-run, and warn that a Terraform-managed firewall config replaces their rules; 9.1 exits 2 when there is no node_modules/next for its patch gate to check. 5.1 now sets X-XSS-Protection: 0: the guide had recommended 1; mode=block, which OWASP warns can introduce XSS, and the pack now flags any other deployed value. 6.3 now uses vercel deploy-hooks, creates the replacement before removing the old hook, and never prints the hook URL; 8.3 never prints CRON_SECRET, names the env-write target explicitly (VERCEL_ORG_ID with VERCEL_PROJECT_ID, without which vercel env add refuses or writes to whichever project the directory is linked to), and reports an unreachable endpoint as 000, not 000000. Seven misfiled cli/ packs fixed: 1.04 and 8.04 moved to api/; 5.01, 6.04, 10.01, and 10.02 moved to config/; and 6.03 was rewritten onto the vercel CLI, so it stays in cli/. #### Code Implementation headings were added to match the template Claude Code (Opus 5.5)
2026-09-25 1.3.1 ai-drafted · ai-validated Added ai-validated to this guide’s status set, which now reads ai-drafted + ai-validated: a validate-hth-guide run exercised part of this guidance against a live Vercel team and it survived that contact. An AI agent did the exercising; no human practitioner has reviewed or applied this guide, so it claims no ni- status. What was exercised (3 surfaces across 2 controls, each marked): 5.1 ClickOps (a live project’s vercel.json headers observed on every route, and SecurityHeaders.com run in a real browser), 5.1 Code (the config/ header-check pack run against a live production domain, its report matched an independent curl -I), and 7.2 ClickOps Step 1 (HTTP 308 to HTTPS, SSL Labs and openssl protocol probes). What was NOT exercised, so it carries no mark: every other console path, because the Vercel dashboard stayed behind its sign-in wall for the whole run; every credentialed read-only pack, because no Vercel token could be minted; every mutating Terraform/API pack; 7.2 Steps 2 and 3. Corrected from live behaviour: 2.1’s Standard Protection scope now says every production domain stays public, including the auto-assigned <project>.vercel.app (unauthenticated probes of a live project: the production vercel.app domain answered 200 while its generated deployment URL redirected to Vercel’s sign-in). Corrected from current Vercel docs (pricing changed): Password Protection is $20/month per protected project on Pro, and Vercel Authentication on All Deployments plus Deployment Protection Exceptions are included on every plan; the $150/month Advanced Deployment Protection add-on, its 30-day minimum, and “Enable and Pay” are gone from 2.1, 2.4 and Appendix A (Vercel now lists that package as legacy). Pack comments in 2.01, 2.04 and variables.tf follow suit (comments and one variable description only; terraform validate and terraform test 20/20 re-run). The 5.1 and 10.1 checks now exit 2, not 1, when they could not run: in 5.1 a failed temp file, jq step or request to the domain (and a header-name read that fails, which had passed as “all present” with nothing checked); in 10.1 a missing or crashed node or a here-document bash cannot create, which had been reported as a finding. 5.1 was re-run against the live production domain after the change and gave the same report and exit 1 Claude Code (Opus 5.5)

Contributing

Found an issue or want to improve this guide?