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.
{
"$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:
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.comMost 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.
{
"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.
{
"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.