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 → SAML Single Sign-On
- 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 Pack: Terraform
resource "vercel_team_config" "saml_enforcement" {
id = var.vercel_team_id
saml = {
enforced = var.saml_enforced
}
}
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 → Directory Sync
- 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 Pack: API Script
# --- Verify team SAML/SCIM configuration ---
echo "=== Directory Sync Configuration ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v2/teams/${VERCEL_TEAM_ID}" | \
jq '{
name: .name,
saml: .saml,
remoteCaching: .remoteCaching,
membership: .membership
}'
# --- List current team members and their roles ---
echo ""
echo "=== Current Team Members ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v2/teams/${VERCEL_TEAM_ID}/members?limit=100" | \
jq '.members[] | {uid, email, role, joinedFrom}'
# --- Audit members for role compliance ---
echo ""
echo "=== Members with Owner Role (should be minimal) ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v2/teams/${VERCEL_TEAM_ID}/members?limit=100" | \
jq '.members[] | select(.role == "OWNER") | {uid, email}'
# --- Verify Access Groups exist (Enterprise) ---
echo ""
echo "=== Access Groups ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/access-groups?teamId=${VERCEL_TEAM_ID}" | \
jq '.accessGroups[]? | {name, membersCount, projectsCount}'
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 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)
- Navigate to: Team Settings → OIDC Federation
- Set issuer mode to Team (recommended over Global)
- Configure cloud provider trust policies (AWS, GCP, Azure)
- Replace static credentials in environment variables with OIDC token references
Time to Complete: ~30 minutes
Code Pack: CLI Script
# --- Audit existing tokens via Vercel API ---
echo "=== Auditing Vercel API Tokens ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v5/user/tokens" | \
jq '.tokens[] | {id, name, activeAt, expiresAt, type}'
# --- List tokens with no expiration (security risk) ---
echo ""
echo "=== Tokens Without Expiration (ACTION REQUIRED) ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v5/user/tokens" | \
jq '.tokens[] | select(.expiresAt == null) | {id, name, createdAt}'
# --- Create a scoped token with 90-day max expiration ---
echo ""
echo "=== Creating Scoped Token (example) ==="
# Uncomment and customize:
# curl -s -X POST -H "Authorization: Bearer ${VERCEL_TOKEN}" \
# -H "Content-Type: application/json" \
# "https://api.vercel.com/v5/user/tokens" \
# -d '{
# "name": "github-actions-deploy",
# "expiresAt": '"$(($(date +%s) + 7776000))000"',
# "type": "oauth2-token"
# }'
# --- Verify OIDC federation status ---
echo ""
echo "=== OIDC Federation Status ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/teams/${VERCEL_TEAM_ID}" | \
jq '{oidcTokenConfig: .oidcTokenConfig}'
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 → 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 Pack: API Script
# --- List all installed Vercel Marketplace integrations for the team ---
echo "=== Installed Vercel Integrations ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/integrations/configurations?teamId=${VERCEL_TEAM_ID}&view=account" | \
jq '.configurations[]? | {
id,
integrationId,
slug,
projects: (.projects | length),
createdAt,
installerEmail: .ownerId
}'
# --- List all connected Git accounts (GitHub/GitLab/Bitbucket) ---
echo ""
echo "=== Connected Git Accounts (review for unused or stale links) ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/integrations/git-namespaces?teamId=${VERCEL_TEAM_ID}" | \
jq '.[]? | {
provider,
name,
slug,
installationId
}'
# --- List projects with fork-protection disabled (supply chain risk) ---
echo ""
echo "=== Projects WITHOUT Git Fork Protection (review immediately) ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v10/projects?teamId=${VERCEL_TEAM_ID}&limit=100" | \
jq '.projects[] | select(.gitForkProtection != true) | {id, name, gitForkProtection}'
# --- List deploy hooks across projects (any hook URL == credential) ---
echo ""
echo "=== Deploy Hooks Inventory (each URL is an unauthenticated trigger) ==="
for project_id in $(curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v10/projects?teamId=${VERCEL_TEAM_ID}&limit=100" | \
jq -r '.projects[].id'); do
hooks=$(curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/projects/${project_id}/deploy-hooks?teamId=${VERCEL_TEAM_ID}" | \
jq -r '.[]? | "\(.id)\t\(.name)\t\(.ref)\t\(.createdAt)"' 2>/dev/null || true)
if [ -n "${hooks}" ]; then
echo "Project: ${project_id}"
echo "${hooks}"
fi
done
# --- Check Protection Bypass for Automation configuration ---
echo ""
echo "=== Projects with Protection Bypass for Automation Enabled ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v10/projects?teamId=${VERCEL_TEAM_ID}&limit=100" | \
jq '.projects[] | select(.passwordProtection != null or .vercelAuthentication != null) | {
id, name,
vercelAuthentication: .vercelAuthentication.deploymentType,
passwordProtection: .passwordProtection.deploymentType,
bypassEnabled: (.protectionBypass != null and (.protectionBypass | keys | length > 0))
}'
# --- External audit checklist: OAuth grants in other identity providers ---
echo ""
echo "=== EXTERNAL AUDIT CHECKLIST (perform in each system) ==="
cat <<'CHECKLIST'
Google Workspace:
admin.google.com -> Security -> API Controls -> Third-party app access
Revoke: unrecognized apps, any with "View and manage all Google Drive files" or
"See, edit, create, and delete Google Drive files" that are not business-critical.
GitHub Organization:
github.com/organizations/<org>/settings/oauth_application_policy
Review: installed OAuth apps and installed GitHub Apps (incl. Vercel app scope).
Restrict Vercel GitHub App to specific repositories, not all-org.
Microsoft Entra ID:
entra.microsoft.com -> Enterprise applications
Filter by "Application type = Enterprise applications" and review consented permissions.
Slack:
<workspace>.slack.com/apps/manage -> Installed apps
Audit scopes for each app; remove any unused integrations.
CHECKLIST
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, and trusted IPs 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 three 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 | Enterprise, or Pro + $150/mo Advanced Deployment Protection add-on | 30-day minimum commitment on Pro add-on |
| Trusted IPs | Enterprise only | IPv4 CIDR allowlist |
Scopes:
| Scope | Plans | What it covers |
|---|---|---|
| Standard Protection | All | Preview + generated URLs; production custom domain remains public |
| All Deployments | Pro, 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 URLs but production custom domains remain public.
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
- 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 — Enterprise or Pro Add-on)
- Enable Password Protection for the appropriate scope
- Set a strong password and rotate quarterly; distribute via secrets manager, never in docs
- For shareable-link scenarios use Deployment Protection Exceptions (Advanced DP) 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
- L3: Disable automation bypass entirely if not required
Time to Complete: ~20 minutes
Code Pack: Terraform
# --- L1: Production branch protection and deployment settings ---
resource "vercel_project" "hardened" {
name = data.vercel_project.current.name
git_repository = var.git_repository != "" ? {
type = var.git_provider
repo = var.git_repository
production_branch = var.production_branch
} : null
# Block deployments from forked repositories
git_fork_protection = var.git_fork_protection_enabled
# Disable preview deployments for tighter control (L2+)
preview_deployments_disabled = var.profile_level >= 2
# Enable skew protection to prevent version mismatches (L2+)
skew_protection = var.profile_level >= 2 ? "12 hours" : null
# Prioritize production builds over preview builds
prioritise_production_builds = true
# Git provider security options
git_provider_options = {
create_deployments = var.profile_level >= 2 ? "only-production" : "enabled"
}
# Vercel Authentication on preview deployments (L1)
vercel_authentication = {
deployment_type = "all_deployments"
}
}
# --- L2: Password-protect preview deployments ---
resource "vercel_project" "preview_password_protection" {
count = var.profile_level >= 2 && var.preview_password != "" ? 1 : 0
name = data.vercel_project.current.name
password_protection = {
deployment_type = "preview"
password = var.preview_password
}
}
# --- L3: Trusted IPs restrict access to known networks (Enterprise) ---
resource "vercel_project" "trusted_ips" {
count = var.profile_level >= 3 && length(var.trusted_ip_addresses) > 0 ? 1 : 0
name = data.vercel_project.current.name
trusted_ips = {
addresses = var.trusted_ip_addresses
deployment_type = "all_deployments"
protection_mode = "trusted_ip_required"
}
}
# --- L3: Disable automation bypass ---
resource "vercel_project" "automation_bypass" {
count = var.profile_level >= 3 ? 1 : 0
name = data.vercel_project.current.name
protection_bypass_for_automation = false
}
# --- Data source to read current project configuration ---
data "vercel_project" "current" {
name = null
id = var.project_id
team_id = var.vercel_team_id
}
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 Pack: Terraform
# --- L1: Git fork protection prevents unauthorized fork deployments ---
# Note: git_fork_protection is configured in hth-vercel-2.01 as part of
# the vercel_project resource.
# --- L2: Require verified (signed) commits for deployments ---
resource "vercel_project" "verified_commits" {
count = var.profile_level >= 2 && var.require_verified_commits ? 1 : 0
name = data.vercel_project.current.name
git_provider_options = {
require_verified_commits = 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 Pack: API Script
# --- Check current project deployment settings ---
echo "=== Project Deployment Configuration ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v9/projects/${VERCEL_PROJECT_ID}?teamId=${VERCEL_TEAM_ID}" | \
jq '{
name: .name,
framework: .framework,
productionDeploymentWorkflow: .productionDeploymentWorkflow,
skewProtection: .skewProtection
}'
# --- List recent deployments with rollout status ---
echo ""
echo "=== Recent Deployments ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v6/deployments?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}&limit=5" | \
jq '.deployments[] | {uid, state, createdAt, meta: .meta.githubCommitMessage}'
# --- Verify skew protection is enabled ---
echo ""
echo "=== Skew Protection Status ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v9/projects/${VERCEL_PROJECT_ID}?teamId=${VERCEL_TEAM_ID}" | \
jq '.skewProtection // "not configured"'
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. Available to Enterprise plans and to Pro teams that opt into the Advanced Deployment Protection add-on ($150/month, 30-day minimum commitment).
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
- The Advanced DP add-on unlocks Password Protection and Deployment Protection Exceptions on Pro without requiring an Enterprise contract
Attack Prevented: Anonymous reconnaissance of production admin surfaces, credential-stuffing at public login pages, automated scanning of production endpoints.
Prerequisites
- Vercel Pro plan + Advanced Deployment Protection add-on ($150/month, minimum 30 days before disabling), or Enterprise plan
- Trusted IP list (if using Trusted IPs) or IdP for Vercel Authentication or password distribution channel
ClickOps Implementation
Step 1: Enable Advanced Deployment Protection (Pro only)
- Navigate to: Project Settings → Deployment Protection
- Choose one of Password Protection, Private Production Deployments (All Deployments), or Deployment Protection Exceptions
- Click Enable and Pay when prompted
- Add-on activates immediately; all Advanced DP features unlock
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: Plan 30-Day Minimum Commitment (Pro Add-on)
- Note the add-on bills for a minimum 30 days before it can be disabled
- Review the use-case quarterly to decide whether to keep, downgrade to Standard Protection, or upgrade to Enterprise
Time to Complete: ~20 minutes (including billing approval)
Code Pack: Terraform
# --- L2: Protect ALL deployments including production domains ---
# This is the "All Deployments" scope — requires Enterprise or the $150/mo
# Advanced Deployment Protection add-on on Pro. Applies to production custom
# domains as well as preview/generated URLs, including Routing Middleware.
resource "vercel_project" "private_production" {
count = var.profile_level >= 2 && var.private_production_deployments_enabled ? 1 : 0
name = data.vercel_project.current.name
team_id = var.vercel_team_id
vercel_authentication = {
deployment_type = "all_deployments"
}
}
# --- L2: Password-protect ALL deployments (prod + preview) ---
resource "vercel_project" "password_all_deployments" {
count = var.profile_level >= 2 && var.private_production_deployments_enabled && var.preview_password != "" ? 1 : 0
name = data.vercel_project.current.name
team_id = var.vercel_team_id
password_protection = {
deployment_type = "all_deployments"
password = var.preview_password
}
}
# --- L3: Only Production via Trusted IPs (Enterprise only) ---
# Leave preview deployments publicly accessible but restrict production domains
# to corporate egress IPs only.
resource "vercel_project" "production_only_trusted_ips" {
count = var.profile_level >= 3 && var.production_only_trusted_ips_enabled && length(var.trusted_ip_addresses) > 0 ? 1 : 0
name = data.vercel_project.current.name
team_id = var.vercel_team_id
trusted_ips = {
addresses = var.trusted_ip_addresses
deployment_type = "production"
protection_mode = "trusted_ip_required"
}
}
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 reflects the $150/month Pro add-on line item (Pro teams)
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 | Moderate | Must wait 30 days before disabling Pro add-on |
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 |
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 Pack: Terraform
# --- L2: Enable Web Application Firewall with OWASP managed rulesets ---
resource "vercel_firewall_config" "waf" {
project_id = var.project_id
team_id = var.vercel_team_id
enabled = var.firewall_enabled
managed_rulesets = {
owasp = {
active = true
action = var.waf_owasp_action
}
}
# Note: Bot Protection and AI Bot rulesets are managed via
# Vercel dashboard or API (not yet in Terraform provider)
}
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 available on all plans (Hobby: 10, Pro: 100, Enterprise: custom)
- 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
ClickOps Implementation
Step 1: Block Known Bad IPs
- Navigate to: Project → Firewall → IP Blocking
- Add known malicious IP addresses or ranges
- 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 Pack: Terraform
# --- L1: Configure firewall with IP blocking rules ---
resource "vercel_firewall_config" "ip_blocking" {
project_id = var.project_id
team_id = var.vercel_team_id
enabled = true
# IP blocking rules
dynamic "rules" {
for_each = var.blocked_ip_addresses
content {
name = rules.value.note != "" ? rules.value.note : "Block ${rules.value.value}"
action = "deny"
active = true
condition_group = [{
conditions = [{
type = "ip_address"
op = "eq"
value = rules.value.value
}]
}]
}
}
# L2: Rate limiting rules for sensitive endpoints
dynamic "rules" {
for_each = var.profile_level >= 2 ? var.rate_limit_rules : []
content {
name = rules.value.name
action = "rate_limit"
active = true
rate_limit = {
limit = rules.value.limit
window = rules.value.window
action = rules.value.follow_up_action
}
condition_group = [{
conditions = [{
type = "path"
op = "pre"
value = rules.value.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:
DenywithactionDuration: 24handpersistentAction: true
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 actionDenyfor1h,persistentAction: true
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 Pack: API Script
# --- Read current firewall configuration ---
echo "=== Current Firewall Configuration ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/security/firewall/config/active?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" | \
jq '{
ruleCount: (.rules | length),
managedRulesets: (.managedRulesets | keys),
ipBlockCount: (.ips | length)
}'
# --- Deploy a persistent-action rule that blocks sources hitting known
# scanner paths for 24 hours on first match (pre-CDN, zero billing cost) ---
echo ""
echo "=== Deploying Persistent-Action Block Rule ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"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",
"persistentAction": true
}
}
}
}
JSON
# --- Rate-limit authentication endpoints with persistent follow-up ban ---
echo ""
echo "=== Deploying Auth Rate Limit with Persistent Ban ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"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",
"persistentAction": true
}
}
}
}
JSON
Validation & Testing
- Repeat probing from a single IP is blocked after the first hit for the configured duration
- Firewall observability shows
persistentAction: trueon matched rules - 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 Pack: API Script
# --- List currently active managed rulesets ---
echo "=== Active Managed Rulesets ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/security/firewall/config/active?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" | \
jq '.managedRulesets // {}'
# --- Enable AI Bots Managed Ruleset in LOG mode first (observe before denying) ---
echo ""
echo "=== Enabling AI Bots Managed Ruleset (log mode) ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"action": "managedRules.update",
"id": "ai_bots",
"value": {
"active": true,
"action": "log"
}
}
JSON
# --- Enable Bot Protection Managed Ruleset in CHALLENGE mode ---
echo ""
echo "=== Enabling Bot Protection Managed Ruleset (challenge mode) ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"action": "managedRules.update",
"id": "bot_protection",
"value": {
"active": true,
"action": "challenge"
}
}
JSON
# --- After 7 days of LOG mode, flip AI Bots to DENY (manual review required) ---
echo ""
echo "=== Switch AI Bots to DENY after review (uncomment when ready) ==="
cat <<'REVIEW'
# Review the Firewall observability dashboard for 7 days to confirm no
# business-critical AI-assistant traffic is being matched. When ready:
#
# curl -X PUT \
# -H "Authorization: Bearer ${VERCEL_TOKEN}" \
# -H "Content-Type: application/json" \
# "https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
# -d '{"action":"managedRules.update","id":"ai_bots","value":{"active":true,"action":"deny"}}'
REVIEW
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 |
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 → Connectivity → 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
- Add projects to the network
- Configure per-environment (Production, Preview, etc.)
- 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 Pack: Terraform
# --- L3: Create Secure Compute network with static IPs ---
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
}
# --- L3: Link project to Secure Compute network ---
resource "vercel_network_project_link" "secure_link" {
count = var.profile_level >= 3 && var.secure_compute_enabled ? 1 : 0
team_id = var.vercel_team_id
network_id = vercel_network.secure_compute[0].id
project_id = var.project_id
}
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 Pack: Terraform
# --- L1: Enable Attack Challenge Mode (activate during active attacks) ---
resource "vercel_attack_challenge_mode" "protection" {
project_id = var.project_id
team_id = var.vercel_team_id
enabled = var.attack_challenge_mode_enabled
}
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 to1; mode=block(legacy but still useful)
Step 3: Validate
- Test with SecurityHeaders.com
- Review CSP reports if using
report-uriorreport-todirective
Time to Complete: ~20 minutes
Code Pack: CLI Script
# --- Deploy vercel.json with security headers ---
# Add this configuration to your project's vercel.json:
cat > /tmp/hth-vercel-headers.json << '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": "1; mode=block"
}
]
}
]
}
HEADERS_EOF
echo "Security headers config written to /tmp/hth-vercel-headers.json"
echo "Merge this into your project's vercel.json, then deploy:"
echo " vercel deploy --prod"
# --- Validate deployed headers ---
echo ""
echo "=== Validating Security Headers ==="
DOMAIN="${1:-}"
if [ -n "${DOMAIN}" ]; then
echo "Checking headers for: ${DOMAIN}"
curl -sI "https://${DOMAIN}" | grep -iE \
"content-security-policy|x-frame-options|x-content-type|referrer-policy|permissions-policy|strict-transport|x-xss"
else
echo "Usage: $0 <your-domain.com>"
fi
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
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.
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 Pack: Terraform
# --- L1: Configure environment variables with sensitivity flags ---
resource "vercel_project_environment_variable" "secrets" {
for_each = var.environment_variables
project_id = var.project_id
team_id = var.vercel_team_id
key = each.key
value = each.value.value
target = each.value.target
sensitive = each.value.sensitive
}
# --- L2: Enforce sensitive environment variable policy at team level ---
resource "vercel_team_config" "sensitive_env_policy" {
count = var.profile_level >= 2 ? 1 : 0
id = var.vercel_team_id
sensitive_environment_variable_policy = "on"
}
# --- L2: Hide IP addresses in observability (privacy hardening) ---
resource "vercel_team_config" "hide_ips" {
count = var.profile_level >= 2 ? 1 : 0
id = var.vercel_team_id
hide_ip_addresses = true
hide_ip_addresses_in_log_drains = true
}
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
- 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 Pack: Terraform
# --- L2: Configure deployment retention to limit exposure of old deployments ---
resource "vercel_project" "deployment_retention" {
count = var.profile_level >= 2 ? 1 : 0
name = data.vercel_project.current.name
deployment_expiration = {
deploymentsToKeep = var.deployments_to_keep
expirationDays = var.deployment_expiration_days
expirationDaysCanceled = var.deployment_expiration_days_canceled
expirationDaysErrored = var.deployment_expiration_days_errored
expirationDaysProduction = var.deployment_expiration_days_production
}
}
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 pattern
api.vercel.com/v1/.+/deploy-hooks/ - 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 Pack: CLI Script
# --- 1. Inventory existing deploy hooks for this project ---
echo "=== Existing Deploy Hooks for ${VERCEL_PROJECT_ID} ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/projects/${VERCEL_PROJECT_ID}/deploy-hooks?teamId=${VERCEL_TEAM_ID}" | \
jq '.[] | {id, name, ref, createdAt}'
# --- 2. Rotate: delete the old hook and create a new one with the same name/ref ---
# Usage: HTH_HOOK_ID=<hook_id> HTH_HOOK_NAME="ci-deploy" HTH_HOOK_REF="main" ./rotate.sh
rotate_hook() {
local old_id="$1"
local name="$2"
local ref="$3"
echo ""
echo "=== Creating replacement hook: ${name} (ref: ${ref}) ==="
local new_hook
new_hook=$(curl -s -X POST \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/projects/${VERCEL_PROJECT_ID}/deploy-hooks?teamId=${VERCEL_TEAM_ID}" \
-d "$(jq -n --arg name "${name}" --arg ref "${ref}" '{name: $name, ref: $ref}')")
local new_url
new_url=$(echo "${new_hook}" | jq -r '.url')
echo "NEW URL: ${new_url}"
echo "STORE THIS IN YOUR SECRETS MANAGER (not in git)"
echo ""
echo "=== Deleting old hook ${old_id} ==="
curl -s -X DELETE \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/projects/${VERCEL_PROJECT_ID}/deploy-hooks/${old_id}?teamId=${VERCEL_TEAM_ID}"
echo "Old hook ${old_id} deleted."
}
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 hooks committed to git (common mistake) ---
echo ""
echo "=== Scanning current git repo for leaked deploy hook URLs ==="
if command -v git >/dev/null 2>&1 && [ -d ".git" ]; then
leaked=$(git grep -E 'api\.vercel\.com/v1/(integrations|projects)/[^/]+/deploy-hooks/[A-Za-z0-9]+' -- '*.yml' '*.yaml' '*.md' '*.sh' '*.ts' '*.js' '*.json' '*.tf' 2>/dev/null || true)
if [ -n "${leaked}" ]; then
echo "WARNING: Deploy hook URL pattern found in git history:"
echo "${leaked}"
echo "Action required: rotate these hooks and remove from git history (git-filter-repo or BFG)."
else
echo "No leaked deploy hook URLs detected in tracked files."
fi
else
echo "(Skipping — not in a git repo or git not installed.)"
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 -S 'deploy-hooks/' | 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 Pack: CLI Script
# 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
IFS='|' PATTERN="$(printf '%s|' "${SECRET_NAME_PATTERNS[@]}")"
PATTERN="${PATTERN%|}"
# Search files that typically declare env vars
TARGETS=(
'.env*'
'*.env'
'next.config.*'
'vercel.json'
'turbo.json'
'*.tf'
'.github/workflows/*.yml'
'.github/workflows/*.yaml'
)
EXIT_CODE=0
echo "=== Scanning for NEXT_PUBLIC_ prefix on secret-shaped names ==="
# Ripgrep if available (faster); fall back to grep
if command -v rg >/dev/null 2>&1; then
SEARCH_CMD=(rg --no-heading --line-number -i -e "NEXT_PUBLIC_[A-Z0-9_]*(${PATTERN})")
else
SEARCH_CMD=(grep -rn -iE "NEXT_PUBLIC_[A-Z0-9_]*(${PATTERN})")
fi
# Run against working-tree; in CI, also consider the diff.
if matches="$("${SEARCH_CMD[@]}" . 2>/dev/null)"; then
if [ -n "${matches}" ]; then
echo "BLOCK: NEXT_PUBLIC_<secret-name> pattern detected — these values ship to the browser:"
echo "${matches}"
EXIT_CODE=1
fi
fi
# --- 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 ==="
if bundle_matches="$(grep -rho "NEXT_PUBLIC_[A-Z0-9_]*" .next 2>/dev/null | sort -u)"; then
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
fi
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 Pack: CLI Script
# --- List all domains configured in the Vercel team ---
echo "=== Vercel Domain Inventory ==="
vercel domains ls
# --- Check for dangling CNAME records pointing to Vercel ---
echo ""
echo "=== Checking for Dangling DNS Records ==="
DOMAINS=$(vercel domains ls 2>/dev/null | awk 'NR>2 {print $1}' | grep -v '^$')
for domain in ${DOMAINS}; do
echo "Checking: ${domain}"
# Check if CNAME points to Vercel
cname=$(dig +short CNAME "${domain}" 2>/dev/null || true)
if echo "${cname}" | grep -qi "vercel\|now\.sh"; then
# Verify the domain resolves to an active deployment
http_code=$(curl -s -o /dev/null -w "%{http_code}" "https://${domain}" 2>/dev/null || echo "000")
if [ "${http_code}" = "000" ] || [ "${http_code}" = "404" ]; then
echo " WARNING: ${domain} has CNAME to Vercel but returns ${http_code} -- possible takeover risk!"
else
echo " OK: ${domain} -> ${cname} (HTTP ${http_code})"
fi
fi
done
# --- Remove a domain no longer in use ---
# Uncomment and customize:
# vercel domains rm "unused-subdomain.example.com"
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 Pack: CLI Script
DOMAIN="${1:-}"
if [ -z "${DOMAIN}" ]; then
echo "Usage: $0 <your-domain.com>"
exit 1
fi
# --- Verify TLS configuration ---
echo "=== TLS Verification for ${DOMAIN} ==="
# Check TLS version and cipher
echo "--- TLS Protocol and Cipher ---"
echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2>/dev/null | \
grep -E "Protocol|Cipher|Server certificate"
# Verify HSTS header
echo ""
echo "--- HSTS Header ---"
curl -sI "https://${DOMAIN}" | grep -i "strict-transport-security" || \
echo "WARNING: No HSTS header found!"
# Verify HTTP to HTTPS redirect
echo ""
echo "--- HTTP Redirect Check ---"
redirect=$(curl -sI -o /dev/null -w "%{http_code}" "http://${DOMAIN}" 2>/dev/null || echo "000")
if [ "${redirect}" = "308" ] || [ "${redirect}" = "301" ]; then
echo "OK: HTTP redirects to HTTPS (${redirect})"
else
echo "WARNING: HTTP returned ${redirect} -- expected 308 redirect"
fi
# --- Issue custom certificate (L3) ---
# Uncomment for custom certificate management:
# vercel certs issue "${DOMAIN}"
# --- List existing certificates ---
echo ""
echo "=== Certificate Inventory ==="
vercel certs ls
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 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 with real-time SIEM streaming 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
- SIEM streaming enables real-time alerting on security-relevant events
- CSV export available for compliance reporting
Attack Prevented: Undetected administrative compromise, unauthorized configuration changes, insider threat
Prerequisites
- Vercel Enterprise plan
ClickOps Implementation
Step 1: Access Audit Log
- Navigate to: Team Settings → Security → Audit Log
- Review available event types and current activity
Step 2: Configure SIEM Streaming
- Navigate to: Team Settings → Security & Privacy → Audit Log → Configure
- Select SIEM destination: AWS S3, Splunk, Datadog, Google Cloud Storage, or Generic HTTP
- Configure authentication (API key, header-based, or AWS credentials)
- Select format: JSON or NDJSON
- Allowlist Vercel SIEM IPs if endpoint is firewalled
Step 3: Build Detection Rules
- Create alerts for critical events:
team.member.role.updated,project.env_variable.created,password_protection.disabled,saml.updated - Monitor for unusual patterns: bulk member additions, env var decryption events, integration installs
Time to Complete: ~30 minutes
Code Pack: API Script
# --- Retrieve recent audit log events (Enterprise) ---
echo "=== Recent Audit Log Events ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/events?teamId=${VERCEL_TEAM_ID}&limit=20&types=team.member.role.updated,project.env_variable.created,saml.updated" | \
jq '.events[]? | {
id,
type,
createdAt,
actor: .actor.slug,
entityId: .entityId
}'
# --- List security-critical event types to monitor ---
echo ""
echo "=== Critical Events for SIEM Alerting ==="
echo "Configure SIEM detection rules for these event types:"
echo " - team.member.role.updated (privilege escalation)"
echo " - team.member.invited (new access grants)"
echo " - team.member.removed (access revocation)"
echo " - project.env_variable.created (secret addition)"
echo " - project.env_variable.updated (secret modification)"
echo " - deployment-protection.updated (protection changes)"
echo " - password_protection.disabled (protection removal)"
echo " - saml.updated (SSO config changes)"
echo " - integration.installed (new integrations)"
echo " - domain.added (domain changes)"
# --- Export audit log CSV (for compliance reporting) ---
echo ""
echo "=== Export Audit Log (last 30 days) ==="
# Navigate to Team Settings > Security > Audit Log > Export CSV
echo "Manual export available at: https://vercel.com/team/${VERCEL_TEAM_ID}/settings/security"
# --- Verify log drain is receiving audit events ---
echo ""
echo "=== Log Drain Status ==="
curl -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v1/log-drains?teamId=${VERCEL_TEAM_ID}" | \
jq '.[] | {id, name, url: .endpoint, status, sources, environments}'
Validation & Testing
- Audit log shows recent administrative events
- SIEM receiving streamed audit events in real-time
- Detection rules firing on test events
- CSV export produces valid compliance report
Expected result: All administrative actions logged, streamed, 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 Pack: CLI Script
# --- Generate a strong CRON_SECRET ---
echo "=== Generate CRON_SECRET ==="
CRON_SECRET=$(openssl rand -hex 32)
echo "Generated CRON_SECRET: ${CRON_SECRET}"
# --- Set CRON_SECRET as production environment variable ---
echo ""
echo "=== Setting CRON_SECRET Environment Variable ==="
vercel env add CRON_SECRET production <<< "${CRON_SECRET}"
# --- Verify cron endpoint rejects unauthenticated requests ---
echo ""
echo "=== Testing Cron Endpoint Security ==="
DOMAIN="${1:-}"
CRON_PATH="${2:-/api/cron}"
if [ -n "${DOMAIN}" ]; then
# Test without auth (should return 401)
echo "Testing without auth header..."
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
"https://${DOMAIN}${CRON_PATH}" 2>/dev/null || echo "000")
if [ "${http_code}" = "401" ]; then
echo " OK: Returns 401 without auth"
else
echo " WARNING: Returns ${http_code} -- expected 401 for unauthenticated request!"
fi
# Test with correct auth (should return 200)
echo "Testing with bearer token..."
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${CRON_SECRET}" \
"https://${DOMAIN}${CRON_PATH}" 2>/dev/null || echo "000")
echo " Auth response: HTTP ${http_code}"
else
echo "Usage: $0 <your-domain.com> [/api/cron-path]"
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/validatewith the intended schema + delivery URL - 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 Pack: CLI Script
# --- Reference receiver (Node.js): verifies x-vercel-signature in constant time ---
# Run: node hth-drain-receiver.js (expects VERCEL_DRAIN_SECRET in env)
cat > /tmp/hth-drain-receiver.js <<'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 /tmp/hth-drain-receiver.js"
echo "Run: VERCEL_DRAIN_SECRET=<drain-secret> node /tmp/hth-drain-receiver.js"
# --- Validate an existing drain's delivery config before going live ---
if [ -n "${VERCEL_TOKEN:-}" ] && [ -n "${VERCEL_TEAM_ID:-}" ] && [ -n "${VERCEL_DRAIN_URL:-}" ]; then
echo ""
echo "=== Validating drain delivery to ${VERCEL_DRAIN_URL} ==="
curl -s -X POST \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/drains/validate?teamId=${VERCEL_TEAM_ID}" \
-d "$(jq -n --arg url "${VERCEL_DRAIN_URL}" '{
schemas: { log: { version: "v1" } },
delivery: { url: $url }
}')" | 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 -s -H "Authorization: Bearer ${VERCEL_TOKEN}" \
"https://api.vercel.com/v2/teams/${VERCEL_TEAM_ID}" | \
jq '{
hideIpAddresses: .hideIpAddresses,
hideIpAddressesInLogDrains: .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
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-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 Known-Patched Version
- Edit
package.jsonto pinnextto an exact version that is at or above the highest fix in the CVE table above (at minimum 15.5.15 or 16.2.3 as of 2026-04) - Commit the lockfile; configure Renovate/Dependabot to propose upgrades as they are released
Step 2: Subscribe to Advisories
- Subscribe the security team to
nextjs.org/blog(security-tagged posts) - Watch
github.com/vercel/next.jsSecurity Advisories tab - Watch
vercel.com/changelog(security tag)
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
- Track the number of days between any Next.js security advisory and that version being deployed to production
- 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 MTTP ≤ 72 hours for critical (CVSS ≥ 9.0)
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 Pack: API Script
# --- WAF rule: DENY requests that carry x-middleware-subrequest from the public internet ---
echo "=== Deploying WAF rule to deny x-middleware-subrequest ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"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": "permanent",
"persistentAction": true
}
}
}
}
JSON
# --- WAF rule: LOG requests carrying x-nextjs-data / Next-Action (exploit precursor) ---
echo ""
echo "=== Logging suspicious Next.js internal headers ==="
curl -s -X PUT \
-H "Authorization: Bearer ${VERCEL_TOKEN}" \
-H "Content-Type: application/json" \
"https://api.vercel.com/v1/security/firewall/config?projectId=${VERCEL_PROJECT_ID}&teamId=${VERCEL_TEAM_ID}" \
-d @- <<'JSON' | jq '.'
{
"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
# --- Report current Next.js version pinned in package.json (patch coverage gate) ---
echo ""
echo "=== Next.js patch coverage ==="
if [ -f package.json ]; then
NEXT_VERSION="$(jq -r '.dependencies.next // .devDependencies.next // empty' package.json)"
if [ -z "${NEXT_VERSION}" ]; then
echo "next not in dependencies — skip."
else
echo "next: ${NEXT_VERSION}"
cat <<'ADVISORIES'
Known high/critical Next.js CVEs (verify your pin is at or above the fix):
CVE-2025-29927 : Middleware auth bypass Fix: 12.3.5 / 13.5.9 / 14.2.25 / 15.2.3
CVE-2024-46982 : Pages Router cache poison Fix: 13.5.7 / 14.2.10
CVE-2024-34351 : Server Actions SSRF Fix: 14.1.1
CVE-2025-49826 : 204 cache poison DoS Fix: 15.1.8
CVE-2025-55182 : React Server Components RCE ("React2Shell") Fix: 15.5.7 / 16.0.7
CVE-2025-55183 : RSC source code exposure Fix: same as React2Shell
CVE-2025-55184 : RSC DoS Fix: same as React2Shell
CVE-2026-23869 : App Router RSC deserialization DoS Fix: 15.5.15 / 16.2.3
ADVISORIES
fi
fi
Validation & Testing
package.jsonpinsnextto a version at or above every fix in the CVE table- 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 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 Pack: CLI Script
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} for permissive remotePatterns ==="
FOUND_ISSUES=0
# Rule 1: hostname wildcards like '**' or 'https://*'
if grep -nE "hostname:\s*['\"](\*\*|\*)['\"]" "${CONFIG_FILE}"; then
echo "BLOCK: bare hostname wildcard in remotePatterns."
FOUND_ISSUES=1
fi
# Rule 2: protocol-only wildcards like protocol: '*'
if grep -nE "protocol:\s*['\"]\*['\"]" "${CONFIG_FILE}"; then
echo "BLOCK: wildcard protocol in remotePatterns."
FOUND_ISSUES=1
fi
# Rule 3: any http:// (non-TLS) remote pattern
if grep -nE "protocol:\s*['\"]http['\"]" "${CONFIG_FILE}"; then
echo "WARN: http:// protocol in remotePatterns — prefer https:// only."
FOUND_ISSUES=1
fi
# Rule 4: missing pathname (allows any path under a host)
if grep -qE "remotePatterns" "${CONFIG_FILE}" && ! grep -qE "pathname:" "${CONFIG_FILE}"; then
echo "WARN: remotePatterns present but no pathname: restriction — any path is allowed."
FOUND_ISSUES=1
fi
# Rule 5: images.domains (deprecated, no pattern granularity)
if grep -nE "^\s*domains:\s*\[" "${CONFIG_FILE}"; then
echo "WARN: images.domains is deprecated and wildcard-prone. Migrate to remotePatterns."
FOUND_ISSUES=1
fi
if [ "${FOUND_ISSUES}" -eq 0 ]; then
echo "OK: ${CONFIG_FILE} /_next/image configuration is restrictive."
else
echo ""
echo "Recommended shape:"
cat <<'TEMPLATE'
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**' },
{ protocol: 'https', hostname: 'avatars.example.com', pathname: '/users/**' },
],
}
TEMPLATE
exit 1
fi
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) - 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
middleware.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 Pack: CLI Script
ROOT="${1:-.}"
EXIT_CODE=0
echo "=== Scanning for middleware-only authorization patterns in ${ROOT} ==="
# 1. Locate a middleware file
MIDDLEWARE=""
for candidate in \
"${ROOT}/middleware.ts" "${ROOT}/middleware.js" \
"${ROOT}/src/middleware.ts" "${ROOT}/src/middleware.js"; do
if [ -f "${candidate}" ]; then
MIDDLEWARE="${candidate}"
break
fi
done
if [ -z "${MIDDLEWARE}" ]; then
echo "(no middleware file found — no middleware-only risk to flag)"
exit 0
fi
echo "Found middleware: ${MIDDLEWARE}"
# 2. Does middleware 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. Find protected route handlers (app/*/route.ts, app/*/page.tsx under matched paths)
MATCHER_PATHS=$(grep -oE "matcher:\s*\[[^]]+\]" "${MIDDLEWARE}" | tr -d "'\"[]" | tr ',' '\n' | awk 'NF')
if [ -z "${MATCHER_PATHS}" ]; then
echo "WARN: cannot detect middleware matcher paths — cannot verify coverage."
EXIT_CODE=1
fi
# 4. For each Route Handler under app/, ensure it also checks auth
if [ -d "${ROOT}/app" ] || [ -d "${ROOT}/src/app" ]; then
APP_DIR="${ROOT}/app"
[ -d "${ROOT}/src/app" ] && APP_DIR="${ROOT}/src/app"
while IFS= read -r handler; do
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 < <(find "${APP_DIR}" -type f \( -name 'route.ts' -o -name 'route.js' \) 2>/dev/null)
fi
# 5. Flag Server Actions ("use server") that lack auth checks
if command -v rg >/dev/null 2>&1; then
while IFS= read -r action_file; do
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 < <(rg -l '"use server"' "${ROOT}" 2>/dev/null || true)
fi
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
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 |
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 | ❌ | Add-on ($150/mo) | ✅ |
| Trusted IPs | 2.1 | ❌ | ❌ | ✅ |
| Git Fork Protection | 2.2 | ✅ | ✅ | ✅ |
| Rolling Releases | 2.3 | ❌ | ✅ | ✅ |
| WAF Custom Rules | 3.1 | 3 rules | 40 rules | 1,000 rules |
| WAF Managed Rulesets | 3.1 | ❌ | ❌ | ✅ |
| IP Blocking (project) | 3.2 | 10 IPs | 100 IPs | Custom |
| IP Blocking (account) | 3.2 | ❌ | ❌ | ✅ |
| Rate Limiting | 3.2 | ❌ | ✅ | ✅ |
| 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 | ❌ | Add-on ($150/mo) | ✅ |
| 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 | ✅ | ✅ | ✅ |
| Drains | 8.1 | ❌ | ✅ | ✅ |
| Audit Logs | 8.2 | ❌ | ❌ | ✅ (90 days) |
| SIEM Streaming | 8.2 | ❌ | ❌ | ✅ |
Appendix B: References
Official Vercel Documentation:
- Vercel Trust Center
- Vercel Documentation
- Security Overview
- Shared Responsibility Model
- Production Checklist
- Deployment Protection
- Vercel Firewall / WAF
- 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.
Security Incidents (Framework — Next.js, maintained by Vercel):
- 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. Navigate to Team Settings → Security → Account Activity and 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 | draft | Initial Vercel hardening guide | Claude Code (Opus 4.5) |
| 2026-02-24 | 1.0.0 | draft | [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 | draft | [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) |