Hosting platform

Content Security Policy on Vercel

On Vercel the policy goes in vercel.json. The file is simple. The traps are the toolbar Vercel adds to preview deployments, and that nothing documents what happens when two of your header rules match the same path.

A complete report-only config

Two header rules that can never both match a request. Your own domains get the plain policy, and *.vercel.app deployment URLs get it plus what the Vercel Toolbar needs.

For a Next.js project on Vercel, prefer the Next.js proxy. A nonce can only come from code that runs per request, and vercel.json is static.

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "headers": [
    {
      "source": "/(.*)",
      "missing": [
        {
          "type": "host",
          "value": {
            "suf": ".vercel.app"
          }
        }
      ],
      "headers": [
        {
          "key": "Content-Security-Policy-Report-Only",
          "value": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint"
        },
        {
          "key": "Reporting-Endpoints",
          "value": "csp-endpoint=\"https://your-ingest-code.ingest.us.headerhawk.com/r\""
        }
      ]
    },
    {
      "source": "/(.*)",
      "has": [
        {
          "type": "host",
          "value": {
            "suf": ".vercel.app"
          }
        }
      ],
      "headers": [
        {
          "key": "Content-Security-Policy-Report-Only",
          "value": "default-src 'self'; script-src 'self' https://vercel.live; style-src 'self' https://vercel.live 'unsafe-inline'; img-src 'self' data: blob: https://vercel.live https://vercel.com; font-src 'self' https://vercel.live https://assets.vercel.com; connect-src 'self' https://vercel.live wss://ws-us3.pusher.com; frame-src 'self' https://vercel.live; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint"
        },
        {
          "key": "Reporting-Endpoints",
          "value": "csp-endpoint=\"https://your-ingest-code.ingest.us.headerhawk.com/r\""
        }
      ]
    }
  ]
}

How this was checked: validated against Vercel's published vercel.json schema, including negative controls — an unknown has type, a misnamed header field — that the schema rejects. Not deployed: Vercel applies header rules on its own network, which cannot be run locally.

Gotcha

The Vercel Toolbar shows up in preview reports

Preview deployments show the Vercel Toolbar by default, for comments and the other tools it carries, and it loads from vercel.live. A policy that allows only your own origin reports it on every preview page load, and an enforcing policy stops the toolbar working. Vercel documents what it needs:

Vercel's list of additions
script-src   https://vercel.live
frame-src    https://vercel.live
connect-src  https://vercel.live wss://ws-us3.pusher.com
img-src      https://vercel.live https://vercel.com data: blob:
style-src    https://vercel.live 'unsafe-inline'
font-src     https://vercel.live https://assets.vercel.com

Most of that has no place on your production domain, and 'unsafe-inline' in style-src least of all. The config scopes it by host with has and missing, so only .vercel.app URLs get it. Your production deployment also answers on one of those URLs and gets the wider policy there, which is fine, since visitors see the one on your domain. A preview on a custom alias domain matches neither host rule's exception, and does not show the toolbar unless you opt in.

Or use one policy everywhere and turn the toolbar off for previews in the project settings.

Gotcha

Nothing documents which header rule wins

The vercel.json reference describes a header rule's fields, not what happens when two rules set the same header on one request. The natural config has that shape, a site-wide policy and a tighter one for an admin area.

Both rules match /admin/settings
{
  "headers": [
    { "source": "/(.*)",       "headers": [{ "key": "Content-Security-Policy-Report-Only", "value": "..." }] },
    { "source": "/admin/(.*)", "headers": [{ "key": "Content-Security-Policy-Report-Only", "value": "..." }] }
  ]
}

Whether /admin/ gets the tight policy, the loose one, or both is something you would learn from a deploy, not a reference, and undocumented behaviour can change without a changelog entry.

Write rules that cannot overlap for the same header. For paths, exclude the specific path from the catch-all with a negative lookahead, the same source syntax Vercel's single-page-app rewrites use. For hosts, pair has with missing, as the config does.

Exactly one rule matches any request
{
  "headers": [
    { "source": "/((?!admin/).*)", "headers": [{ "key": "Content-Security-Policy-Report-Only", "value": "..." }] },
    { "source": "/admin/(.*)",     "headers": [{ "key": "Content-Security-Policy-Report-Only", "value": "..." }] }
  ]
}

Now see what it's blocking

A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.

HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.

The Vercel integration guide covers that step: where the endpoint goes on Vercel, and how to confirm the headers are being served.

A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.

Start reading the reports

Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.