Security Headers on Vercel for Static Sites

Vercel takes care of HTTPS: certificates are automatic, HTTP redirects to HTTPS, and Strict-Transport-Security is sent on its domains. The rest of a static site's security headers — Content Security Policy, MIME sniffing protection, framing, referrer and permissions policies — are the site's job, configured in the headers array of vercel.json. The configuration is short, with three things to get right: making the rules cover every path, ordering them so path-specific headers override the general ones, and handling preview deployments whose tooling needs a slightly different policy.

This guide adds a full header set to an Eleventy marketing and documentation site deployed to Vercel, with a strict CSP generated at build time, path-specific additions, a relaxed policy only on preview hosts, and a per-deploy test. It belongs to Security Headers and Hardening for Static Sites; caching headers on the same platform are covered in Cache-Control Headers on Vercel for Static Sites.

Prerequisites

  • A static site deployed to Vercel with vercel.json in the repository.
  • The list of third-party origins the site uses, and any inline scripts that must remain (with their hashes).
  • A preview URL to test against before production.

Step 1: The Site-Wide Headers

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=()" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" }
      ]
    }
  ]
}

Vercel already sends HSTS, but setting it explicitly documents the intended value and controls includeSubDomains, which matters for a custom apex domain with other subdomains. Check that every subdomain serves HTTPS before including them, as in Enabling HSTS and Preload Safely.

Step 2: A CSP Generated at Build Time

The CSP depends on the build: the hash of the one inline script changes when that script does. Rather than maintain it by hand in vercel.json, the build writes it. A post-build script reads the final HTML, computes hashes of executable inline scripts and patches vercel.json's CSP entry before Vercel reads it — Vercel reads vercel.json after the build command runs, so the patched file applies to the deployment:

// scripts/write-csp.mjs (runs at the end of the build command)
const hashes = await inlineScriptHashes('_site');   // as in the CSP violations guide
const csp = [
  "default-src 'self'", `script-src 'self' ${hashes.join(' ')} https://plausible.io`,
  "style-src 'self'", "img-src 'self' data: https://images.example-cdn.com", "font-src 'self'",
  "connect-src 'self' https://plausible.io", "frame-ancestors 'none'", "base-uri 'self'",
  "form-action 'self' https://forms.example.com", "object-src 'none'", 'upgrade-insecure-requests',
].join('; ');
const cfg = JSON.parse(await fs.readFile('vercel.json', 'utf8'));
cfg.headers.find((h) => h.source === '/(.*)').headers.push({ key: 'Content-Security-Policy', value: csp });
await fs.writeFile('vercel.json', JSON.stringify(cfg, null, 2));

The repository's committed vercel.json has no CSP; the build adds it. A test in CI asserts that the generated hash matches the script in the built HTML, so a later step that changes the HTML would fail the build rather than ship a broken policy. The policy ran as Content-Security-Policy-Report-Only for a week before enforcement.

Build steps that generate the CSP header for Vercel Five steps. Eleventy builds and minifies the HTML. A script computes hashes of executable inline scripts in the final HTML. It writes the Content-Security-Policy into vercel.json's site-wide headers entry. Vercel reads vercel.json after the build and applies the headers to the deployment. A test checks the hash matches the shipped script. Where the CSP comes from Build + minify Eleventy Hash inline scripts final HTML Patch vercel.json CSP entry Vercel applies to the deployment Hash test in CI
The committed vercel.json never contains a stale hash, because the build writes the current one.

Step 3: Path-Specific Headers

Later entries override earlier ones for the same header key, so path-specific entries come after the site-wide one:

{ "source": "/embed/(.*)",
  "headers": [
    { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
    { "key": "Content-Security-Policy", "value": "default-src 'self'; frame-ancestors https://partner.example.org; base-uri 'self'; object-src 'none'" }
  ] },
{ "source": "/downloads/(.*)\\.pdf",
  "headers": [{ "key": "Content-Disposition", "value": "attachment" }] }

The /embed/ pages are designed to be framed by one partner site, so they replace the deny-all framing rules with an explicit frame-ancestors allowance. PDFs download rather than render inline, which avoids PDF viewers executing embedded scripts from user-supplied documents.

Step 4: Previews

Vercel's preview toolbar, used by reviewers for comments, loads scripts and frames from Vercel's own origins, which the strict production CSP blocks. A host-conditioned entry, placed last, relaxes the policy only for preview hosts:

{ "source": "/(.*)",
  "has": [{ "type": "host", "value": "(?<h>.*-acme\\.vercel\\.app)" }],
  "headers": [{ "key": "Content-Security-Policy-Report-Only", "value": "default-src 'self' https://vercel.live https://*.vercel.app; …" }] }

On preview hosts, the build script writes the strict policy as Report-Only instead of enforced — reports from previews still reveal real problems before they reach production, while the toolbar works. Production on the custom domain gets the enforced policy. Fixing Preview Deploys Leaking Into Search Results uses the same has mechanism for noindex.

Security header behaviour on production and preview hosts A matrix of two hosts against four headers. On the production custom domain, CSP is enforced, framing is denied, HSTS is set and nosniff is set. On preview hosts, CSP is report-only with Vercel toolbar origins allowed, framing is denied, HSTS is set by Vercel, and nosniff is set. Policy by host Header Production domain Preview hosts Content-Security-Policy enforced report-only + toolbar X-Frame-Options DENY DENY Strict-Transport- Security 2 years, subdomains Vercel default X-Content-Type-Options nosniff nosniff
Previews still report CSP problems; only enforcement is relaxed so review tools work.

Collecting CSP Reports on a Static Deployment

A static site has no server to receive CSP reports, so the reporting endpoint is the one small function on the project — a Vercel Function at /api/csp-report that validates the payload's shape, drops reports from browser extensions, and forwards the rest to the team's logging service. It handles both the older report-uri format and the Reporting API's report-to format, which browsers send with different content types.

Daily CSP reports from site code during the report-only week Vertical bars of daily reports from site code. Day 1, 2,100 reports. Day 2, 1,900. Day 3 after moving the analytics snippet to a script tag, 640. Day 5 after removing onclick attributes, 90. Day 7 after hashing the theme script, 0. CSP reports during the report-only week 2100 Day 1 1900 Day 2 640 Day 3 90 Day 5 0 Day 7
Reports after filtering out browser extensions; enforcement started after two clean days.

The function is the only server code in the deployment, and it is rate-limited at the edge to protect the logging service from report floods, which a single misbehaving extension can produce.

Step 5: Test Every Deploy

A check runs against each preview and against production after deploy:

for u in / /docs/install/ /assets/main.css /embed/widget/ /nope/; do
  h=$(curl -sI "$BASE$u")
  for k in content-security-policy x-content-type-options referrer-policy strict-transport-security; do
    echo "$h" | grep -qi "^$k:" || { echo "missing $k on $u"; fail=1; }
  done
done
[ "$BASE" = "https://example.com" ] && curl -sI "$BASE/" | grep -qi '^content-security-policy-report-only' && { echo "prod is report-only"; fail=1; }
exit ${fail:-0}

The last line guards against the preview relaxation ever matching production.

When Headers Need to Change

Most header changes come from adding a third-party service — a new video embed, a form provider, an analytics tool. The team's process: the pull request that adds the service also adds its origins to the CSP generator's allow-lists, and the preview's report-only policy shows within minutes whether anything was missed. Reviewers check that each new origin is as narrow as possible (a specific hostname rather than a wildcard) and has a comment naming the feature that needs it, so the list can be pruned when features are removed.

Measured Impact

MeasureBeforeAfterTool
Mozilla Observatory gradeC (HSTS only)A+Observatory
Security headers on 404 pageHSTS onlyFull setHeader test
CSP violations from site code (enforced)—0 in 4 weeksReport endpoint
Preview toolbar working—Yes (report-only on previews)Manual
Manual CSP hash updates—0 (generated)Repository history

The header test now runs nightly against production as well, so a change made outside the repository is caught within a day.

Pitfalls & Rollback

  • Path-specific entries before the site-wide one. Later entries win; order matters.
  • Hand-maintained hashes. Generate them in the build from the final HTML.
  • Breaking preview tooling with a strict CSP. Relax only on preview hosts, in report-only mode.
  • Relying on headers on redirect responses. Test redirects separately.
  • Rollback: headers deploy with the site; Vercel's instant rollback restores the previous deployment's headers along with its files.

Conclusion

On Vercel, HTTPS and HSTS are handled for you and everything else goes in vercel.json. A site-wide header entry, a CSP generated from the built HTML with the exact inline-script hash, path-specific overrides placed after it, report-only enforcement on preview hosts and a header test on every deploy took this site from a C to an A+ without any hand-maintained hashes.

FAQ

Does Vercel add security headers automatically?

Vercel enforces HTTPS and sends a Strict-Transport-Security header on its own domains and custom domains, but it does not add a Content-Security-Policy, X-Content-Type-Options, Referrer-Policy or Permissions-Policy for static sites. Those are configured in the headers array of vercel.json.

How do I apply headers to every path in vercel.json?

Use a source pattern that matches every path, such as slash, open parenthesis, dot star, close parenthesis, in a headers entry. Entries are applied in order and more than one can match a request; a later entry setting the same header key replaces the earlier value for that request.

Do vercel.json headers apply to the 404 page and redirects?

Headers apply to responses served from the deployment, including the custom 404 page. Redirect responses defined in vercel.json are issued before headers are applied in some cases, so check redirects separately with curl and do not rely on security headers on 3xx responses.

Should preview deployments have the same CSP as production?

Mostly yes, so that previews catch CSP problems before production. The exception is anything that breaks Vercel's preview tooling, such as the toolbar used for comments, which needs its own origins allowed. A host-conditioned header entry can relax the policy on preview hosts only.

Filed underHow-tosVercel