I run a small static site, a browser-based checker that helps YouTube creators see whether a video is likely to hit monetization problems. It has no backend, so everything is plain HTML plus a vercel.json. Early on I added a strict Content-Security-Policy and HSTS, because a site that asks people to open their video files should at least behave. A few commits later I added AdSense, and the two collided.
The trap: AdSense is not one script. The loader at pagead2.googlesyndication.com pulls in more scripts, frames and beacons from several Google domains. If your CSP says default-src 'self', ads just don't show and nothing on the page tells you why. The only clue is the browser console.
Where the policy lives
On Vercel I set headers in vercel.json so every route gets them:
{ "cleanUrls": true, "headers": [ { "source": "/(.*)", "headers": [ { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://pagead2.googlesyndication.com https://*.googlesyndication.com https://*.doubleclick.net https://*.google.com https://*.adtrafficquality.google https://*.googleadservices.com; ..." }, { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" } ] } ] } That is trimmed; the real value is one long line. Here is how I think about each directive that AdSense touches.
Directive by directive
script-src. The loader and the scripts it injects come from googlesyndication.com, doubleclick.net, google.com, googleadservices.com and adtrafficquality.google. I allow wildcard subdomains for those. I also kept 'unsafe-inline' because the page has its own inline scripts, and I'll be honest: that weakens the policy. The proper fix is nonces or hashes, which on a purely static host means generating them at build time. I haven't done that yet.
connect-src. Ad scripts make fetch and beacon calls. Missing this one gives you ads that half-render. Allow the same Google hosts plus google-analytics.com and analytics.google.com if you use GA.
frame-src. The ad itself is an iframe. If frame-src isn't set it falls back to default-src 'self' and the slot stays empty. This is the one I'd check first if you see blank boxes.
img-src. Ads and tracking pixels are images from all over. I use https: here, which is broad on purpose; images are the lowest-risk resource type.
Things I did not loosen. object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors 'self' stay strict. Nothing in AdSense needs them open, so don't.
ads.txt is a separate file, not a header
AdSense also wants an ads.txt at the site root. On a static site that's literally a file next to index.html:
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0 (the publisher ID is yours; the last field is Google's fixed certification ID). Because cleanUrls is on, make sure it's served as plain text and not rewritten. I check with curl -I https://your-site/ads.txt and look for a 200 and text/plain.
How I test a CSP change
I don't trust "it looks fine in my browser". My routine is short:
- Deploy to a Vercel preview URL.
- Open DevTools, Console, and filter for "Content Security Policy". Every blocked resource is logged with the directive that blocked it.
- Fix one directive at a time. Resist the urge to paste a giant allowlist from a forum answer; each host you add should be one you saw blocked.
- Re-check on Firefox and Safari too. They word violations differently, and I had separate bugs on those two while building the video scanner, so I stopped assuming Chrome behavior is the whole story.
What I'd do differently
- Start with
Content-Security-Policy-Report-Onlyfirst. You get the console messages without breaking anything for visitors, then flip to enforcing once it's quiet. - Move inline scripts to files so
'unsafe-inline'can go. This is the biggest remaining hole in my setup. - Keep the CSP in one reviewed place. Mine is one line of JSON, which is awful to diff. A small build step that assembles it from a list would make reviews sane.
My own site is a single index.html plus a vercel.json, so it is a small example to compare against. The live tool is at YT Policy Shield if you want to see the policy in action in the response headers.
Anyone else running ads on a strict-CSP static site? I'd like to know whether you went nonce-based or gave up on inline scripts entirely.