Vercel Hardening Guide
Comprehensive platform security for authentication, WAF, deployment protection, secrets, network isolation, security headers, and monitoring
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
- Authentication & Access Controls
- Deployment Security
- Web Application Firewall
- Network Security
- Security Headers
- Secrets Management
- Domain & Certificate Security
- Monitoring & Detection
- Framework CVE Management (Next.js)
- 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
- Navigate to: Team Settings → Security & Privacy → Authentication and User Provisioning, then select Configure on the SAML row
- Select your identity provider from the 24+ supported providers
- Configure the SAML connection following your IdP’s instructions
- Map IdP groups to Vercel roles (vercel-role-owner, vercel-role-member, etc.)
Step 2: Enforce SAML
- After confirming SSO works: Toggle Enforce SAML to ON
- Distribute custom login URL:
https://vercel.com/login?saml=<team_id> - Verify session duration is 24 hours (default – re-authentication required after)
Time to Complete: ~30 minutes
Code Implementation
Code Pack: Terraform
# 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
- Attempt login without SAML – should be blocked when enforcement is ON
- Login via IdP – should succeed and land on team dashboard
- 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
- Navigate to: Team Settings → Security & Privacy → Authentication and User Provisioning, then select Configure on the Directory Sync row
- Generate SCIM endpoint URL and bearer token
- Configure your IdP with the SCIM endpoint
Step 2: Map Groups to Roles
- Create IdP groups matching Vercel roles:
vercel-role-owner,vercel-role-member,vercel-role-developer,vercel-role-security,vercel-role-billing - Map IdP groups to Access Groups for project-level permissions
- Ensure at least one owner mapping exists to prevent lockout
Time to Complete: ~45 minutes
Code Implementation
Code Pack: API Script
# 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
- Add a test user in IdP – should appear in Vercel team within sync interval
- Remove test user from IdP group – should lose Vercel access
- 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
- Navigate to: Team Settings → Members
- Review all members and their assigned roles
- Identify over-privileged accounts (Owners who should be Members, etc.)
Step 2: Implement Least Privilege
- Downgrade accounts to minimum required role
- Use Contributor role + project-level assignments for granular access
- Create Access Groups for team-based project permissions
- Assign Permission Groups additively (Create Project, Full Production Deployment, etc.)
Step 3: Configure Access Groups (Enterprise)
- Navigate to: Team Settings → Access Groups
- Create groups aligned to team structure (e.g., “Frontend Team”, “Platform Team”)
- Assign projects with appropriate roles (Admin, Developer, Viewer)
- Link to Directory Sync groups if SCIM is configured
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Terraform
# --- 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
- Developer role cannot modify production environment variables
- Security role can manage firewall but cannot deploy
- Viewer role has read-only access with no deploy capability
- 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
- Navigate to: Account Settings → Tokens
- Review all active tokens for scope and expiration
- Delete unused or overly-scoped tokens
Step 2: Create Scoped Tokens
- Create new tokens with minimum required scopes
- Set expiration to shortest practical duration (max 90 days)
- Use descriptive names indicating purpose (e.g., “github-actions-deploy”)
Step 3: Implement OIDC Federation (Preferred)
- For each project, navigate to: Project Settings → Security → Secure backend access with OIDC federation
- Set issuer mode to Team (recommended over Global) and select Save
- Configure cloud provider trust policies (AWS, GCP, Azure)
- Replace static credentials in environment variables with OIDC token references
Time to Complete: ~30 minutes
Code Implementation
Code Pack: API Script
# 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
- No tokens exist with unlimited expiration
- OIDC federation provides short-lived credentials (60-min TTL)
- All CI/CD pipelines use scoped tokens or OIDC
- 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
readscope
ClickOps Implementation
Step 1: Vercel-side Inventory
- Navigate to: Team Settings → Integrations – list all installed Marketplace integrations and the projects each has access to. Remove anything unused.
- Navigate to: Team Settings → Git – review connected Git namespaces. Remove stale installations.
- For each project: Project Settings → Git – confirm the Vercel GitHub App is scoped to specific repositories rather than entire organizations.
- 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)
- 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. - 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. - Microsoft Entra ID:
entra.microsoft.com→ Enterprise applications. Review consented permissions for each Enterprise application. - 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
# 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
- All Marketplace integrations have a business owner on record
- The Vercel GitHub App is scoped to specific repositories (not org-wide) for every connection
- Every deploy hook URL is stored in a secrets manager and rotated ≤90 days ago
- 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
- Navigate to: Team Settings → Deployment Protection
- Configure the team default protection method + scope so new projects inherit the hardened baseline
- Individual projects can override the default when legitimately required
Step 2: Enable Standard Protection on Each Existing Project (L1, All Plans)
- Navigate to: Project Settings → Deployment Protection
- 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.
- The API reports this scope as
- 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)
- Enable Password Protection for the appropriate scope (on Pro, enabling it adds the per-project charge; disabling it stops future charges)
- Set a strong password and rotate quarterly; distribute via secrets manager, never in docs
- For shareable-link scenarios use Deployment Protection Exceptions (included on every plan) rather than disabling protection
Step 4: Configure Trusted IPs (L3 — Enterprise)
- Add office and VPN egress IP ranges as trusted IPs
- Set protection mode to Trusted IP Required
- 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
- Navigate to: Project Settings → Deployment Protection → Protection Bypass for Automation
- If required, generate a bypass secret of 32+ random characters; exposed to builds via
VERCEL_AUTOMATION_BYPASS_SECRET - Callers may present the secret via header
x-vercel-protection-bypassor query parameter?x-vercel-protection-bypass=...(query form is required for Slack/Stripe webhook URL verification that cannot set headers) - For iframe scenarios, add query parameter
?x-vercel-set-bypass-cookie=samesitenone - Bypass does not override active DDoS mitigations, rate limits during attacks, or attack-triggered challenges — defense-in-depth is preserved
- Regenerating or deleting a bypass secret invalidates previously-deployed builds; a redeploy is required to take effect
- 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
- L3: Disable automation bypass entirely if not required
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Terraform
# --- 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
- Unauthenticated access to preview URL returns login prompt
- Password-protected deployment requires correct password
- Access from non-trusted IP is blocked (Enterprise)
- Automation bypass secret is 32+ characters if enabled
- Requests to Routing Middleware without auth are rejected at the edge
- 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
- Navigate to: Project Settings → Git
- Ensure Git Fork Protection is enabled (blocks deployments from forked repos without approval)
Step 2: Restrict Deployment Creation (L2)
- Set Create Deployments to Only Production – prevents preview deployments from PRs
- Or set to Disabled for fully manual deployment control
Step 3: Require Verified Commits (L2)
- Enable Require Verified Commits in Git provider options
- Configure commit signing in your Git provider (GPG or SSH keys)
Step 4: Review Connected Repositories
- Navigate to: Team Settings → Integrations
- Audit all connected Git repositories
- Remove access to repositories no longer in use
- Limit repository access to specific repos rather than full organization access
Time to Complete: ~10 minutes
Code Implementation
Code Pack: Terraform
# 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
- Fork deployment is blocked without explicit approval
- Unsigned commits fail deployment (when verified commits enabled)
- Only authorized repositories are connected
- 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=trueto a URL. Do not use 0% stages to stage secret pre-release changes; use Deployment Protection Exceptions instead. - The
vcrrForceStable=true/vcrrForceCanary=truequery 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)
- Navigate to: Project Settings → Advanced → Skew Protection
- Set maximum skew window to
12 hoursor the minimum your deployment cadence supports
Step 2: Configure Rolling Release
- Navigate to: Project Settings → Build & Deployment → Rolling Releases
- Choose Manual Approval for production deployments
- Configure stages (e.g., 5% → 25% → 100%) — the last stage must always be 100%
- Set duration for automatic advancement if using automatic mode
Step 3: Document Rollback Path
- Confirm Instant Rollback is available from the Deployments page or REST API (
POST /v1/projects/{projectId}/rollback/{deploymentId}) - 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
# 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
- New deployment starts at first stage percentage
- Manual approval required before advancing (if configured)
- Skew Protection prevents client/server mismatch for the configured window
- Rollback available at any stage
- 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
- Navigate to: Project Settings → Deployment Protection
- Vercel Authentication with the All Deployments scope needs no purchase on any plan
- 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)
- Trusted IPs requires Enterprise
Step 2: Choose a Scope
- All Deployments: preview + production + generated URLs all require authentication
- Only Production Deployments (Trusted IPs, Enterprise): production domain restricted to trusted IPs; preview remains publicly accessible for iteration
- Standard Protection + Exceptions: keep standard protection, explicitly grant named exceptions for external services or shareable preview links
Step 3: Configure Method
- Select Vercel Authentication (team members only), Password Protection (strong password distributed via secrets manager), or Trusted IPs (Enterprise)
- For production-only Trusted IPs, set
protection_mode = trusted_ip_requiredanddeployment_type = production
Step 4: Review Scope and Cost Quarterly
- On Pro, Password Protection bills each protected project monthly; confirm every charged project still needs it
- 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
# 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
- Unauthenticated request to production domain returns the Vercel auth/password gate
- Non-trusted-IP request to production is blocked at the edge (Enterprise Trusted IPs scope)
- Deployment Protection Exceptions work for the specific named paths/services only
- 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
.mapfiles 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
.mapfiles from the deployment
ClickOps Implementation
Step 1: Audit Existing Projects
- For every project created before the feature shipped, request a known
.mapURL from the production deployment while logged out - Any
200response means the project is exposed and must be remediated in Step 2
Step 2: Enable the Toggle
- Navigate to: Project Settings → Deployment Protection → Protected Source Maps
- Toggle to Enabled
- Confirm new projects inherit the enabled default; do not assume the setting propagated to existing ones
Step 3: Understand the Scope Boundary
- Protection covers browser
.mapfiles served from the deployment only - 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
- If your build inlines source maps, remove the inlining before relying on this control
Step 4: Repair Legitimate Consumers
- 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
- Human debugging of a protected deployment goes through Vercel Toolbar → Debug Mode rather than a raw fetch
Step 5: Verify via API (Optional)
- The same setting is exposed on the REST API project object as
protectedSourcemaps;PATCH /v9/projects/{id}with that field set totrueflips it programmatically for bulk remediation
Time to Complete: ~15 minutes (plus inventory time proportional to project count)
Code Implementation
Code Pack: Terraform
# 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
- An unauthenticated request for a known
.mappath returns 404 - An authenticated team member using Vercel Toolbar Debug Mode can still retrieve the map
- The error tracker continues to receive uploaded maps from CI (bypass header present)
- 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
.mapprobe 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-subrequestat 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:
- DDoS mitigation (automatic, all plans)
- WAF IP blocking
- WAF custom rules (including Persistent Actions — see Section 3.3)
- 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
- Navigate to: Project → Firewall
- Toggle firewall to Enabled
Step 2: Enable OWASP Managed Rulesets (Enterprise)
- Navigate to: Firewall → Rules → WAF Managed Rulesets
- Enable OWASP Core Ruleset in Log mode first
- Monitor live traffic in the Firewall observability view for 48-72 hours and tune false positives
- Switch to Deny mode rule-by-rule after tuning
Step 3: Enable Bot Protection (Challenge)
- From Firewall → Rules → Bot Management, set the Bot Protection managed rule to Challenge
- Verified bots (Googlebot, webhook providers, services on the bots.fyi directory) are auto-allowed
- For custom automated clients, add a WAF Custom Rule with a Bypass action matching your
User-AgentorSignature-Agentheader
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+)
- Create rules for application-specific protection; always start in Log mode
- For GitOps, declare rules in
vercel.jsonunderroutes[].mitigate— but note onlychallengeanddenyactions are supported in config-as-code;log,bypass, andredirectare dashboard-only - 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
# 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
- WAF is enabled and processing traffic (check Firewall tab)
- OWASP rules detecting common attack patterns in logs
- Bot protection challenging automated requests
- 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
- Navigate to: Project → Firewall → IP Blocking
- Add known malicious IP addresses or ranges — stay within the per-plan limits above
- Use per-host blocking for domain-specific rules
Step 2: Configure Rate Limiting Rules (Pro+)
- Navigate to: Firewall → Configure → Rules
- 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
- Set follow-up action to Deny with persistent duration (e.g., 5 minutes)
- Use Log action first to validate thresholds
Step 3: Enable Persistent Actions
- Configure persistent actions on deny/challenge rules
- 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
# 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
- Blocked IPs return 403/challenge response
- Rate-limited endpoints enforce configured thresholds
- Persistent actions block repeat offenders
- 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
- Review Firewall observability for the top 10 probed paths (typical:
/.env,/.git,/wp-admin,/admin,/phpmyadmin) - Identify sensitive endpoints that must not be scanned (
/api/auth/*,/api/billing/*)
Step 2: Create Persistent Deny Rule for Scanner Paths
- Navigate to: Firewall → Rules → Custom Rules → Create Rule
- Name:
hth-persistent-block-scanners - Condition:
pathstarts with any of/.env,/.git,/wp-admin - Action (Then):
Deny, and set the timeframe dropdown (for) to 24 hours. That timeframe is the persistent action (API fieldactionDuration); removing it disables persistence
Step 3: Create Persistent Rate Limit on Auth Endpoints
- Create rule named
hth-auth-rate-limit-persistent - Condition:
pathstarts with/api/auth - Action:
Rate Limit(20 req/min, fixed-window, keyed by IP) with follow-up actionDenyand the for timeframe set to 1 hour
Step 4: Review Weekly
- From Firewall observability, verify Persistent Actions are firing against expected traffic
- Adjust thresholds or add exceptions for false positives
Time to Complete: ~20 minutes
Code Implementation
Code Pack: API Script
# 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
- Repeat probing from a single IP is blocked after the first hit for the configured duration
- The rules show a persistent timeframe (
actionDuration) in the firewall configuration, and Firewall observability shows follow-up requests from a matched source being blocked - 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
- Navigate to: Firewall → Rules → Bot Management → AI Bots Ruleset
- Set action to Log
- Save and publish
Step 2: Observe for 7 Days
- 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
- If content is proprietary or not licensed for AI training, switch to Deny
- 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
- 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
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
- Known AI crawler user-agents hit the rule when probing the site
- Firewall observability shows AI Bot traffic volume before/after the action change
- 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.tsintegration path, or a supported alternative (Nuxt module;vercel.jsonrewrites 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)
- Navigate to: Dashboard → Project → Firewall → Rules → Vercel BotID Deep Analysis
- Enable it if you want ML classification on top of the free Basic tier
- Note the Pro metering: $1 per 1,000
checkBotId()calls — scope the protected route list accordingly
Step 2: Install and Wire the Package (Code)
- Install the
botidpackage into the application - Wrap the framework config — for Next.js, wrap
next.configwithwithBotId(); Nuxt uses the dedicated module; other frameworks proxy the BotID endpoints viavercel.jsonrewrites - This step is what routes the challenge traffic; skipping it silently disables enforcement
Step 3: Declare Protected Routes Client-Side
- Call
initBotId()with aprotectarray of(path, method)pairs ininstrumentation-client.ts(Next.js 15.3+), or render the<BotIdClient />component with the same declarations - 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
- At the top of each protected handler, call
checkBotId()and return 403 when the result reportsisBot - Treat this as authorization code: it belongs in the handler, not in middleware (Section 10.2)
- Gotcha: in local development
checkBotId()always reportsisBot: falseunlessdevelopmentOptionsis configured — never conclude from a passing local test that enforcement works
Step 5: Observe and Tune
- Use the Firewall tab traffic dropdown and filter on BotID to see classification volume per route
- 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
// 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
- A headless-browser request to a protected route receives 403; a normal browser request succeeds
- Every route passed to
checkBotId()server-side also appears in the client-sideprotectdeclarations - Deep Analysis shows classification events in the Firewall tab’s BotID filter (if enabled)
- Verification was performed against a deployed environment, not local dev
- 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
- List all middleware files and edge-runtime functions in the project
- 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
- Move any such calls into Node.js/Python/Ruby function runtimes before enabling backend IP allowlisting
Step 2: Create Secure Compute Network
- Navigate to: Team Settings → Networking, then select Create Network
- Select AWS region closest to your backend
- Configure CIDR block (must not overlap with VPC peer ranges)
- Select availability zones
Step 3: Assign Projects
- In each project, navigate to: Project Settings → Networking
- Set the Active Network per environment (Production, Preview, etc.), and optionally a Passive Network for failover
- Optionally include build container (adds ~5s provisioning delay)
Step 4: Configure VPC Peering (Optional, max 50 per network)
- Create peering connection from Vercel dashboard
- Accept in AWS VPC dashboard
- Update route tables in both VPCs
- Configure security groups to allow Vercel IP ranges
Step 5: Update Backend Allowlists
- Add Vercel dedicated IPs to backend database firewall rules
- Add to API gateway IP allowlists
- Always layer authentication on top of IP filtering — IP alone is not sufficient
Step 6: Configure Region Failover
- Create Active + Passive networks in different regions
- Link both to each project environment
- 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
# --- 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
- Functions connect to backend via private network
- Backend rejects connections from non-Vercel IPs
- Region failover switches to passive network on outage
- 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)
- DDoS protection is always enabled — no configuration required
- Verify in: Project → Firewall — should show traffic monitoring
Step 2: Configure Attack Challenge Mode (During Attacks)
- Navigate to: Project → Firewall → Bot Management → Attack Challenge Mode
- Enable during active attacks — challenges browser traffic with a JS challenge
- Same-account Vercel requests (your Functions, Cron Jobs, cross-project calls) auto-bypass
- Verified bots auto-bypass
- Disable when attack subsides; Vercel recommends ACM as a targeted-attack tool, not a permanent setting
- 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-Agentorx-api-key) before enabling ACM
Step 3: Configure Spend Management (Pro+)
- Navigate to: Team Settings → Billing → Spend Management
- Set usage thresholds with automatic actions
- Configure webhook notifications for usage spikes
- 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+)
- Create rules to ensure essential traffic (trusted proxies, known partner IP ranges) is never blocked
- Use for business-critical external services
Time to Complete: ~10 minutes
Code Implementation
Code Pack: Terraform
# --- 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
- DDoS mitigation active (always on – verify via Firewall dashboard)
- Attack Challenge Mode can be enabled/disabled
- Spend management alerts configured
- 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
- Add a
headersconfiguration block to yourvercel.json - Apply to all routes using
source: "/(.*)"pattern
Step 2: Required Security Headers
Content-Security-Policy: Define allowed content sources (most impactful header)X-Frame-Options: Set toDENYorSAMEORIGINX-Content-Type-Options: Set tonosniffReferrer-Policy: Set tostrict-origin-when-cross-originPermissions-Policy: Restrict browser features (camera, microphone, geolocation, etc.)X-XSS-Protection: Set to0to switch the legacy XSS auditor off. Never1; 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
- Test with SecurityHeaders.com
- Review CSP reports if using
report-uriorreport-todirective
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Config
# 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
- All six security headers present in response
- SecurityHeaders.com score of A or A+
- No CSP violations in browser console for legitimate resources
- 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)
- Navigate to: Team Settings → Security & Privacy → Environment Variable Policies
- Toggle Enforce Sensitive Environment Variables to Enabled (requires Owner role)
- All newly-created Production and Preview environment variables will now default to Sensitive and cannot be read back
Step 2: Retrofit Existing Variables
- Navigate to: Project Settings → Environment Variables
- 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.)
- Rotate the underlying secret value at the source system during this process (post-incident hygiene)
Step 3: Audit for Client-Bundle Leakage
- Verify no secrets use
NEXT_PUBLIC_prefix - Add the Section 6.4 lint to CI to enforce this automatically going forward
Step 4: Scope Variables Properly
- Production secrets: Scope to Production only
- Preview/staging secrets: Use separate, lower-privilege credentials for Preview — never reuse production credentials
- Use branch-specific preview variables when different branches need different configs
- Use shared (team-level) variables for consistent cross-project configuration — mark these Sensitive too
Step 5: Local Development Secret Handling
- Because Sensitive env vars are not available in Development, do not store local-dev credentials in Vercel env vars
- Use an out-of-band secret manager (1Password, HashiCorp Vault, Doppler,
.env.localviavercel env pullfor OIDC tokens only) - Document the team’s local-secrets workflow in an engineering handbook
Step 6: Implement OIDC Federation (L2)
- Replace static cloud credentials with OIDC tokens (see Section 1.4) — eliminates the long-lived credential problem entirely
- 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
# --- 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
- Enforce Sensitive Environment Variables toggle is Enabled at Team level
- Every environment variable in every project has the Sensitive tag (production + preview)
- No
NEXT_PUBLIC_variables contain secret values - Production secrets not accessible in preview environment
- Every secret value is ≥32 characters, so build-log redaction engages; any shorter legacy secret has been rotated
- Local dev workflow does not depend on Vercel-stored Development env vars for secrets
- 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
- Navigate to: Project Settings → Deployment Retention
- Set production retention: 1 year (or per compliance requirement)
- Set preview retention: 1 month
- Set errored/canceled retention: 1 week
Time to Complete: ~5 minutes
Code Implementation
Code Pack: Terraform
# --- 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
- Retention policies set per environment type
- 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
- Navigate to: each Project → Settings → Git → Deploy Hooks
- Record every hook ID, name, and ref (branch)
- Identify the consumer (CI job, partner webhook, internal service) for each hook
Step 2: Rotate
- For each hook: create a new hook with the same name + ref, capture the new URL, store in secrets manager
- Update every consumer to use the new URL
- Verify consumers are succeeding against the new hook
- Delete the old hook
Step 3: Harden Vercel GitHub App Scope
- Navigate to: **github.com/organizations/
/settings/installations** - Locate the Vercel GitHub App installation
- Change repository access from All repositories to Only select repositories — restrict to the specific repositories that actually deploy to Vercel
- This limits the blast radius if a deploy hook URL is abused
Step 4: Scan for Leaked URLs
- Search git history, CI configuration files, and documentation for the documented hook URL shape
api.vercel.com/v1/integrations/deploy/prj_…/…(regexapi\.vercel\.com/v1/integrations/deploy/prj_[A-Za-z0-9]+/[A-Za-z0-9]+) - If any matches are found in files tracked in git, rotate those hooks and remove the URL from git history (
git-filter-repoor BFG Repo-Cleaner)
Time to Complete: ~30 minutes per project
Code Implementation
Code Pack: CLI Script
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
- Every deploy hook URL is stored only in a secrets manager — not in git-tracked files
- All deploy hook consumers succeed with rotated URLs
- Vercel GitHub App is restricted to specific repositories, not org-wide
git log -p -G 'api\.vercel\.com/v1/integrations/deploy/' | headreturns 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
greporrgavailable in the CI environment (default on all Vercel build containers)
ClickOps Implementation
Step 1: Add the Lint Script to CI
- Save the pack script (
hth-vercel-6.04-block-next-public-secret-leaks.sh) into your repo atscripts/ci/check-next-public-secrets.sh - Add a required CI step that runs the script before
vercel build - Fail the build if the script exits non-zero
Step 2: Add a Pre-Commit Hook (Developer-side)
- Install a pre-commit framework (e.g.,
pre-commit.com) - Register the script to run on every commit touching
.env*,next.config.*, orvercel.json - Developers get immediate local feedback before pushing
Step 3: Review Compiled Bundle
- After every production build, run the script’s bundle-scan mode against the
.next/output - Any
NEXT_PUBLIC_*name matching a secret pattern surfaces in the build log
Step 4: Quarterly Spot-Check
- Fetch the production site’s main JavaScript bundle with
curl grep -o 'NEXT_PUBLIC_[A-Z0-9_]*'on the bundle- Confirm no secret-shaped names are present
Time to Complete: ~15 minutes
Code Implementation
Code Pack: Config
# 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
- The lint script exits non-zero when a test commit introduces
NEXT_PUBLIC_SECRET_KEY=foo - CI blocks merges that trigger the failure
- A fetch of the production bundle shows no secret-named
NEXT_PUBLIC_*identifiers - 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
- Navigate to: Team Settings → Domains
- Review all configured domains
- Identify any domains not actively assigned to projects
Step 2: Clean Up Dangling Records
- Remove DNS CNAME records for decommissioned Vercel projects
- Remove Vercel domain assignments when projects are deleted
- Verify all domains resolve to active deployments
Step 3: Monitor Domain Health
- Periodically scan for dangling DNS records using DNS auditing tools
- Set up alerts for domain configuration changes via audit logs
Time to Complete: ~15 minutes
Code Implementation
Code Pack: CLI Script
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
- All DNS records pointing to Vercel have active deployments
- No orphaned domain entries in Vercel dashboard
- 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
includeSubDomainsandpreload - 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
- Confirm HTTPS enforced (automatic – HTTP 308 redirects to HTTPS)
- Verify TLS 1.2+ in use via SSL Labs test
- Confirm forward secrecy enabled on all ciphers
Step 2: Enhance HSTS for Custom Domains (L2)
- Add custom header:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload - Submit custom domain to HSTS Preload list at hstspreload.org
Step 3: Deploy Custom Certificates (L3)
- Use
vercel certs issue [domain]for custom certificate management - Upload organization-specific certificates if required by policy
Time to Complete: ~10 minutes
Code Implementation
Code Pack: CLI Script
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
- SSL Labs grade A+ with HSTS preloading
- No TLS 1.0/1.1 negotiation possible
- All ciphers support forward secrecy
- 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-signatureheader; 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
- Navigate to: Team Settings → Drains → Create Drain
- Schema:
logv1 - Destination: custom HTTPS endpoint (or native integration for Dash0 / Braintrust)
- Environments: Production and Preview
- Sources: static, edge, external, build, lambda, firewall
- Generate and record a strong shared secret; store in your secrets manager
Step 2: Create a Separate Firewall Log Drain (L2)
- Because firewall logs are high-signal security events, route them to a dedicated destination (or a security-specific index in your SIEM)
- Create a second drain with schema
logv1, sources =[firewall]
Step 3: Configure Trace Drain (L2)
- Create a third drain with schema
tracev1 for distributed tracing (OpenTelemetry format) - Useful for latency investigations and correlating security events with application spans
Step 4: Enable IP Address Visibility Control (GDPR Hardening)
- Navigate to: Team Settings → Security & Privacy → IP Address Visibility
- 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)
- This strips public IPs from drain payloads before delivery
Step 5: Configure Sampling (Optional)
- For high-volume projects, set per-drain sampling
- Use 1.0 (100%) for security-critical projects (firewall, audit)
- Lower rates acceptable for development/preview
Time to Complete: ~20 minutes
Code Implementation
Code Pack: Terraform
# --- 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
- Log drain receiving events in SIEM
- Payload signature verification working
- Firewall logs appearing for blocked requests
- 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
- Navigate to: Team Settings → Security & Privacy → Audit Log
- Review available event types and current activity
Step 2: Create an Audit Log Drain
- Navigate to: Team Settings → Drains → Add Drain
- Select data type Audit Log
- Select destination: Amazon S3, Splunk, Datadog, Panther, or a custom HTTPS endpoint
- Configure destination authentication (API key, header-based, or AWS credentials)
- Record the drain secret in your secrets manager and verify deliveries per Section 8.4
- Via the REST API the same drain is created with the
schemasproperty set toaudit_logversionv1; pre-flight a custom endpoint with the validate-drain-delivery-configuration call before going live
Step 3: Build Detection Rules
- 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 - 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 - 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
- Monitor for unusual patterns: bulk member additions, env var decryption events, integration installs
Time to Complete: ~30 minutes
Code Implementation
Code Pack: API Script
# 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
- Audit log shows recent administrative events
- The Audit Log Drain is delivering events to the SIEM in real time (legacy Custom SIEM Log Streaming has been retired)
- Drain payload signatures validate per Section 8.4
- Detection rules firing on test events, including a test
passport-access-grantedevent if Passport is in use - 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
- Generate:
openssl rand -hex 32(minimum 16 characters) - Add as production environment variable:
CRON_SECRET
Step 2: Verify in Application Code
- Check
Authorization: Bearer <CRON_SECRET>header in every cron handler - Return 401 for missing or mismatched secrets
- Vercel automatically sends the bearer token when invoking cron endpoints
Time to Complete: ~10 minutes
Code Implementation
Code Pack: CLI Script
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
- CRON_SECRET set as production environment variable
- Direct HTTP request without bearer token returns 401
- 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
- Navigate to: **Team Settings → Drains →
→ Settings** - Generate a new secret; record it in your secrets manager
- The receiver will load this secret from an environment variable, never from disk or source
Step 2: Deploy a Signature-Validating Receiver
- Use the reference receiver from the pack below (Node.js) or an equivalent in your stack
- Receiver reads raw body, computes
hmac_sha1(SECRET, body), compares constant-time tox-vercel-signature - Reject non-matching deliveries with HTTP 401
Step 3: Validate Delivery Configuration
- Call
POST https://api.vercel.com/v1/drains/test(Validate Drain delivery configuration) with the intendedschemasanddelivery(type,endpoint,encoding,headers); Vercel sends sample events without creating a drain - Confirm Vercel can reach the receiver and the receiver accepts the signature
Step 4: Configure IP Address Visibility (GDPR)
- Navigate to: Team Settings → Security & Privacy → IP Address Visibility
- Confirm the
hideIpAddressesandhideIpAddressesInLogDrainssettings match your privacy posture
Step 5: Rotate Secret Quarterly
- From the Drains dashboard, rotate the drain secret
- Update the receiver’s environment variable
- 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
# --- 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
- Receiver returns 401 for requests with missing or invalid
x-vercel-signature - Receiver returns 200 for authentic Vercel deliveries
- A deliberately-modified payload is rejected even if
x-vercel-signatureis present - Constant-time comparison is used (Node
crypto.timingSafeEqual, Pythonhmac.compare_digest, etc.) - 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.localesentry — 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 auditon every build - WAF Custom Rules available on the plan (Pro+)
ClickOps Implementation
Step 1: Pin Next.js to a Supported LTS Channel
- Next.js version policy is now channelled: pin to the Active LTS line (currently
16.2.x) or the Maintenance LTS line (currently15.5.x) rather than to an arbitrary exact version chosen once and forgotten - 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
- 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
- Since 2026-07-13, Next.js runs a preannounced monthly security-release program: roughly a month ahead of each release,
nextjs.org/blogpublishes 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) - 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
- Ad-hoc out-of-band patches still ship for urgent, in-the-wild cases; the monthly cadence does not replace an emergency path
- Subscribe the security team to
nextjs.org/blog(security-tagged posts), watch thegithub.com/vercel/next.jsSecurity Advisories tab, and watchvercel.com/changelog(security tag) - 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)
- 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) - Add a Log Custom Rule for requests containing
x-nextjs-dataorNext-Action— these are exploit precursors that should not arrive from the public internet in normal operation - 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)
- 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
- Track two numbers: days from pre-announcement to a merged upgrade branch, and hours from release publication to production deploy
- 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
- 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
- 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
# 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
package.jsonpinsnextto 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)- A request with
x-middleware-subrequestheader from the public internet is denied at the Vercel Firewall - Requests with
x-nextjs-dataorNext-Actionare logged and surface in Firewall observability - Renovate/Dependabot has proposed the latest Next.js patch; its PR is merged within MTTP target
- 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-Actionprobes - 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/imagewas 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 toremotePatternswith explicitpathnamerestrictions 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.*
- Open
next.config.js/next.config.mjs/next.config.ts - Locate
images.remotePatterns - Remove any entries with
hostname: '**',hostname: '*',protocol: '*', orprotocol: 'http' - Add explicit
pathnamerestrictions ('/images/**', not'/**') - Delete any
images.domainsentries (deprecated); migrate toremotePatterns
Step 2: Add the Section-10.1 Lint to CI
- Save the pack script (
hth-vercel-10.01-next-image-remotepatterns-audit.sh) intoscripts/ci/ - Add as a required CI step; fail the build on any detected permissive pattern
Step 3: Add an Edge WAF Rule (Defense in Depth)
- Create a WAF Custom Rule (Section 3.1 workflow) that denies requests to
/_next/imagewhose decodedurl=query parameter resolves to RFC1918, link-local, or loopback ranges - 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
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
- Lint script exits non-zero against a synthetic permissive config (
hostname: '**') - Production
next.config.*has no wildcard hostnames or protocols - WAF rule blocks a test request:
/_next/image?url=http://169.254.169.254/(from the public internet) - 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
- Locate
middleware.ts(ormiddleware.js) — on Next.js 16 the same file convention is namedproxy.ts(Next.js: Proxy), and it is the same non-boundary - Extract every path matched by
config.matcher - For each path, locate the Route Handler, Server Component, or Server Action implementation
Step 2: Add In-Handler Authorization
- For every Route Handler (
app/**/route.ts), add an explicitawait getSession()/await getUser()check at the top of the handler - For every Server Component that renders protected data, repeat the session check inside the component
- For every Server Action (
"use server"file), repeat the session check inside the action function — theNext-Actionheader alone is not sufficient authorization
Step 3: Add the Section-10.2 Lint to CI
- Save the pack script (
hth-vercel-10.02-middleware-authz-defense-in-depth.sh) intoscripts/ci/ - CI runs the script on every PR; script flags Route Handlers / Server Actions that lack an apparent in-handler authorization check
- Treat warnings as blocking for paths covered by the
middleware.ts/proxy.tsmatcher
Step 4: Document the Pattern in Code-Review Checklist
- Add a line item to your PR review template: “Does every protected endpoint authorize inside the handler, not only in middleware?”
- Include in engineering onboarding materials
Time to Complete: ~1 hour per protected path cluster (initial) + ongoing
Code Implementation
Code Pack: Config
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
- A simulated
x-middleware-subrequestprobe against a protected Route Handler is rejected by the handler even when middleware is skipped (reproduce in a local test by mocking middleware-skip) - Lint script reports zero in-handler warnings for protected paths
- 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
dig <your-domain>— if the CNAME resolves to Cloudflare / CloudFront / Azure Front Door before Vercel’s edge, you are proxied- 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
- Record the decision and the rationale in the team’s architecture documentation
-
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
- Track Bot Protection false-positive rate for 14 days after any topology change
- 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
- 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
- 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
RUNstep - 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
- Navigate to: Project dashboard → Images
- For each repository, open Settings and record whether Public Access is enabled
- Treat any repository whose public status nobody can explain as an incident until proven intentional
Step 2: Gate the Toggle Behind Change Control
- Enabling public access requires typing the repository name to confirm — treat that confirmation as the last step, not the approval
- Require a documented approval (security review of the image contents plus a named business owner) before anyone reaches the toggle
- 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
- Pull the exact tag and inspect every layer for secrets, internal registry credentials, private package tokens, and proprietary source
- Rebuild from a clean, secret-free Dockerfile rather than attempting to scrub an existing image — deleted files persist in earlier layers
- Confirm the base image and any vendored dependencies are ones you are licensed to redistribute
Step 4: Fold Into the Quarterly Audit
- Add registry visibility to the Section 1.5 third-party/integration audit checklist
- Re-confirm each public repository still has a business owner and still needs to be public
- 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
# --- 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
- Every Container Registry repository’s visibility is recorded, with a named owner for each public one
- No repository was made public without a documented approval
- A layer inspection of each public image finds no secrets, credentials, or proprietary source
- Push and delete operations from an unauthorized account are rejected (only pull is public)
- 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:
- Vercel Trust Center
- Vercel Documentation
- Security Overview
- Shared Responsibility Model
- Production Checklist
- Deployment Protection
- Protected Source Maps
- Passport
- Vercel Firewall / WAF
- Vercel BotID
- Sensitive Environment Variables
- DDoS Mitigation
- Secure Compute
- Encryption
- RBAC Access Roles
- SAML SSO
- Directory Sync
- OIDC Federation
- Audit Logs
- Log Drains
- Environment Variables
- Security & Compliance
- Security Bulletins
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:
- Next.js preannounced monthly security-release program (from 2026-07-13) — Active LTS / Maintenance LTS channels, advance notice of date and anticipated severity, out-of-band path retained for urgent cases
- July 2026 Next.js security release — CVE-2026-64641 through CVE-2026-64649; fixed in 16.2.11 (Active LTS) and 15.5.21 (Maintenance LTS)
Platform Changelog (deprecations and new surfaces):
- Audit Log Drains now support Datadog, Splunk, and Panther — replaces Custom SIEM Log Streaming as of 2026-08-07
- Vercel Container Registry repositories can now be made public — 2026-08-07
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.localesentry (same outcome class as CVE-2025-29927), two SSRF classes (rewrites()/redirects()destination construction and Server ActionsHost-header handling on custom servers), Server Action endpoint-ID disclosure,/_next/imageSVG 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-subrequestbypasses 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:
- zhero_web_security research index — primary discoverer of CVE-2025-29927 and related middleware class-bugs
- Assetnote Research — Next.js SSRF, middleware-bypass due-diligence
- Datadog Security Labs — Understanding CVE-2025-29927 — detection engineering perspective
- Praetorian — CVE-2025-66478 RCE with working exploit
- ProjectDiscovery — CVE-2025-29927 technical analysis
- Zscaler ThreatLabz — CVE-2025-29927
- JFrog — CVE-2025-29927 analysis
- Checkmarx Zero — CVE-2025-29927
- Cremit — Vercel secret exposure case study — 0.45% of public Vercel deployments leak API keys via
NEXT_PUBLIC_ - GitGuardian — Vercel April 2026 incident analysis
- Trend Micro — Vercel OAuth supply chain breach
Industry Commentary (Contrarian Voices):
- Shubham Sharma — Next.js Vendor Lock-In Architecture
- Eduardo Bouças — You should know this before choosing Next.js — build time as security metric
- WAFPlanet — Vercel Firewall independent review
Community Security Research:
- Vercel XSS in Clone URL (Medium) — Reflected XSS in clone functionality
- Vercel Subdomain Takeover (Medium) — Dangling CNAME exploitation
- dSSRF: Deterministic SSRF Protection — Community SSRF protection library
- Next.js Security Checklist (Arcjet) — Framework-level hardening guide
- Nosecone Security Headers Library — Open source security headers for Next.js
- OpenSourceMalware/vercel-april2026-incident-response — Community-maintained IR playbook
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)
- Enable team MFA enforcement for all members. Require authenticator apps or passkeys; disable SMS as a second factor.
- Audit account activity logs. Open the team’s Activity Log (dashboard → Activity, or
vercel activityfrom 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. - 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. - 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.
- 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.
- 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)
admin.google.com→ Security → API Controls → Third-party app access.- Search for OAuth app ID
110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com— the Vercel-documented IOC. Revoke if present in any user’s granted apps. - Review all unrecognized third-party apps and any Drive-permissioned apps that are not business-critical; revoke aggressively, re-grant only on demand.
- Repeat for GitHub Organization OAuth apps and GitHub Apps (restrict Vercel GitHub App scope per Section 6.3).
- Repeat for Microsoft Entra Enterprise Applications and Slack Installed Apps.
C.3 Deployment and Code Investigation
- 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.
- Check Git provider audit logs (GitHub Audit Log, GitLab Audit Events) for suspicious deploy-hook invocations, webhook installs, or GitHub App permission changes.
- Rotate all deploy hooks per Section 6.3. Treat existing hook URLs as burned.
- 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
- Enable Enforce Sensitive Environment Variables (Section 6.1, Step 1). Make this the permanent baseline.
- Enable Deployment Protection (Section 2.1) at Standard minimum across every project; regenerate any Deployment Protection automation bypass tokens.
- Install the Section 6.4 lint in CI to prevent
NEXT_PUBLIC_secret regressions. - 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.
- Subscribe to Vercel KB Bulletin for future incidents and to Next.js security advisories (Section 9.1).
C.5 Long-Term Program Changes
- 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.
- 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.
- Measure MTTP (mean time to patch) for Next.js CVEs per Section 9.1. Target ≤72 hours for critical.
- 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
- 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.
- Publish an internal postmortem referencing this playbook. Future responders need to know what was done.
- 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?
- Report outdated information: Open an issue with tag
content-outdated - Propose new controls: Open an issue with tag
new-control - Submit improvements: See Contributing Guide