DEV Community

hb lai
hb lai

Posted on

Running third-party ads on a site with a strict CSP (without weakening the CSP)

My static site had a deliberately tight Content-Security-Policy: default-src 'self', a short allowlist for analytics, nothing else. Then I added an ad network, and hit the obvious wall. Ad creatives come from domains you cannot enumerate ahead of time. Every host the network might serve from would have to be in script-src, img-src, frame-src. That is not an allowlist any more, that is a shrug.

Here is the approach that kept the main policy intact, plus the Cloudflare detail that cost me an afternoon.

Put the ad in its own document

Instead of pasting the ad script into the page, I gave it a file of its own:

<!-- /ads/banner.html -->
<script>
  atOptions = { key: '...', format: 'iframe', height: 250, width: 300, params: {} };
</script>
<script src="https://ads.example.com/.../invoke.js"></script>
Enter fullscreen mode Exit fullscreen mode

The page then embeds that file in an iframe:

<iframe
  src="/ads/banner.html"
  width={300} height={250}
  sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
/>
Enter fullscreen mode Exit fullscreen mode

The main document's CSP only needs frame-src 'self'. Everything the ad loads is somebody else's problem, inside a frame that has no reach into my DOM.

A second benefit I didn't plan for: this ad network reads a global atOptions object, so two ad units on one page overwrite each other's config. One iframe per slot gives each its own global scope. If you have ever wondered why your second ad unit renders at the first one's dimensions, this is why.

The Cloudflare _headers trap

Now the ad document needs a looser policy than the site. On Cloudflare Workers Static Assets you'd write:

/*
  Content-Security-Policy: default-src 'self'; ...

/ads/*
  Content-Security-Policy: default-src * data: blob: 'unsafe-inline' 'unsafe-eval'
Enter fullscreen mode Exit fullscreen mode

Nope. _headers rules merge. They don't override. Both policies get sent, and when a browser receives two CSP headers it enforces the intersection, which is the strictest combination. So my "permissive" rule changed nothing; the ad stayed blocked by the strict one. Same story for X-Frame-Options: DENY from the site plus SAMEORIGIN from the override means DENY wins, and the iframe won't even mount.

curl -I shows the smoking gun. Same header name, twice:

content-security-policy: default-src 'self'; ...
content-security-policy: default-src * data: blob: ...
x-frame-options: DENY
x-frame-options: SAMEORIGIN
Enter fullscreen mode Exit fullscreen mode

The fix is to remove the inherited header with ! rather than trying to outrank it:

/ads/*
  ! Content-Security-Policy
  ! X-Frame-Options
  Cache-Control: public, max-age=300
Enter fullscreen mode Exit fullscreen mode

Now the ad document ships with no CSP of its own, the main site keeps its strict one, and frame-src 'self' lets my own pages frame it.

Don't trust your own eyes when testing ad delivery

This one wasted more of my time than the header bug. The ad script kept returning 403, so I assumed my setup was broken and went looking for a cause. It wasn't broken. Ad networks refuse requests that look like fraud, and my testing setup looked exactly like fraud from four directions at once:

  • a VPN/proxy exit IP
  • a region the network barely serves
  • a headless browser
  • opening invoke.js directly in the address bar. It's meant to be loaded as an embedded <script src>, so a bare hit with no referer gets refused on principle

All four produce a 403 that is indistinguishable from "the network isn't serving your site yet". I only got a straight answer by loading a real page over mobile data, where the ad appeared immediately. If you're debugging fill, trust the publisher dashboard's impression count and a normal residential connection, not your dev environment.

Sizing note

If you run a responsive layout, swap units rather than scaling one. A 728x90 leaderboard squeezed into a phone viewport is an overflow bug. Put a hidden sm:block desktop unit next to a block sm:hidden mobile unit and exactly one renders, so you get the right shape and a single impression.


The site I did this on is a fan database for a fighting game. The kind of project where ad revenue should cover hosting without costing you the security posture or the page weight. If you're curious what it turned into, the move database and the controls and inputs reference are the parts I'm happiest with, and there's an honest platform availability answer for the question people ask most.

Top comments (0)