Boards
← The Pub

Brave blocked our ads and told us nothing

by richard · Aug 6, 2026 · edited

I spent an evening last week convinced I had written a bug. I had not. The ads on my new site were being blocked by the browser, silently, and every diagnostic I had said everything was fine.

pasted image

Here is the setup. I run a pixel ad network called Pixboard, and I had just split off a publishing site, ReadingPub, where anyone can post an article for free. The whole thing is paid for by one narrow column of ads down the left edge -- forty pixels wide, one advertiser per square. That column was an iframe pointing at the ad network. It is how ad embedding has worked for twenty years, and it took about three lines to install.

On my screen, the column was empty.

So I started pulling on threads. The server was returning ads: fourteen of them, and not just to me. I asked for that column the way a browser at the top level would, the way a same-site frame would, and the way a cross-site frame with my publishing site's referer would. Fourteen every time. Not a server bug.

Maybe the frame was being torn down. It was not: the iframe element sat there at forty-six by eight hundred and thirty-nine pixels the whole time, same source, never resized, never hidden. Maybe something inside the frame was clearing it after load. There was nothing inside the frame to do that -- the ad page has zero scripts in it. Maybe I had broken it with my own security attributes, because I had put a sandbox on the iframe. So I dropped a second, completely bare iframe onto the same page, no sandbox, no referrer policy, nothing. Same empty column.

And the console was clean. No Content Security Policy violation. No failed resource. Nothing.

I want to dwell on that, because it is the part that cost me the most time. I had a page that was visibly wrong and a browser telling me, in every channel I knew how to check, that nothing had gone wrong. When your instruments all read normal and the thing in front of you is broken, the temptation is to distrust your eyes and keep rechecking the instruments. I rechecked the instruments for a long time.

I did find one real bug of my own along the way, and it is worth a paragraph because it is a trap. My sandbox attribute did not include allow-same-origin. Sandboxing an iframe without that gives the framed page a unique, opaque origin -- it is no longer "from" the domain it was served by. The ad page's own security policy says its images may load from 'self', and with an opaque origin, 'self' matches nothing at all. So my sandbox was silently blocking the ad network's own images. That was genuinely my fault, I fixed it, and it made no difference to the problem I was actually chasing. Two bugs stacked on top of each other is how an evening disappears.

The thing that finally cracked it was noticing what survived.

Inside that frame, one image was rendering perfectly: the small Pixboard mark at the foot of the column, served from the ad network's own domain. The fourteen advertiser creatives, which come off a separate CDN under a path containing the word creative, were not. And they were not failing to load. They appeared, and then about a second later they went away.

Load, then hide. That is not a network failure. A network failure never paints. That is something watching the page and removing elements after the fact, which is precisely what the cosmetic filtering in an ad blocker does.

It was Brave Shields. My own browser. And the reason I never suspected it is that Brave's badge counter -- the little number that tells you how many things it blocked on this page -- said zero. Zero badges, and a completely blocked ad column.

Once you see it, the surprise evaporates. Look at what I had built from a blocker's point of view. A third-party iframe pointing at a different domain. Images on a separate CDN under a path called creative. CSS class names ending in -ad. Every single signal that ad blockers exist to recognize, stacked in one component. uBlock Origin, AdGuard and Safari's built-in protections all behave the same way. I had not built an unlucky edge case. I had built the exact shape of the thing being blocked.

Which means the interesting problem is not "why is my column empty." It is: if you are a publisher, a meaningful share of your readers are seeing nothing where your ads are, and nothing in your stack will tell you. Your ad server logs a request, because the server was asked. Your analytics record a pageview, because the page loaded. The advertiser's impression counter goes up. The reader sees a blank strip. Everyone's numbers agree and everyone is wrong.

The fix turned out to be conceptually simple and worth stating precisely, because there is a version of it that does not work and it is the version most people reach for first.

You cannot fix this by moving the ads from an iframe to a JavaScript call. I want to be blunt about that, because "just fetch the ads as JSON and render them into a div" sounds like it removes the iframe problem. It removes the iframe. It does not remove the third party. A fetch() from the reader's browser to the ad network's domain is a cross-origin request to a known ad host, and it gets blocked exactly as hard as the frame did. The blocker does not care what API you used.

What works is moving the conversation off the reader's machine entirely. My server asks the ad network for the column. My server renders those ads into my own HTML. The images are streamed through my own domain, and the clicks leave through my own domain before landing on the network's tracking link, so the network's click accounting is untouched. From the browser's point of view there is no third party involved anywhere on the page, because there genuinely is not one. There is nothing to recognize.

That is called first-party ad serving, and it is not a trick. The ads are still labelled as advertising on the page. Every link still carries rel="sponsored", which is the honest markup for a paid placement and tells search engines exactly what it is. A reader who wants to block these can still block them. What has gone away is the automatic, invisible, third-party signal doing it on their behalf on a site where the publisher is serving its own advertisers directly.

It costs something, and I would rather quote the number than hand-wave it. Fetching that column server-side adds about seventy milliseconds to rendering a page. I could cache it and pay that once a minute instead of once a pageview, and for most people that is the obvious move. I am not doing it, for a reason specific to how this network meters: it counts one serving per call and gates each advertiser's paid budget on that count. If I cached for sixty seconds, one fetch would cover every pageview in that window, the impression count would collapse, budgets would never deplete, and advertisers would quietly receive far more exposure than they paid for. That is not a rounding error in a dashboard, it is the money model. So the network is adding a report-back endpoint, and until it exists I will pay the seventy milliseconds and keep the meter honest.

Two things I would tell anyone building the publisher side of this. Do not ship one drop-in library that uses the same URL paths on every site it is installed on. If every publisher proxies their creatives through /media/ and their clicks through /out/, that is one filter rule away from taking down every site at once, and you will have rebuilt the exact single point of failure you were escaping. Make the paths per-publisher. And do not reach for CNAME delegation as the shortcut -- pointing ads.yoursite.com at the ad network looks like first-party serving and is not; uBlock unmasks CNAMEs, Brave blocks known cloaked hosts, and you get per-publisher TLS headaches as a bonus.

I will also say the unglamorous part out loud: this is not permanent. Cosmetic filter rules can be written against my markup tomorrow. First-party serving raises the cost of blocking a specific publisher enormously -- it has to be done by hand, per site, rather than by one rule covering every site that embeds a given network -- but it is an advantage, not a victory. Anyone selling you a permanent solution to this is selling you something.

What I actually take from the evening is smaller and more useful than any of that. The bug was not hard. The bug was invisible, and it was invisible in a very specific way: every measurement I had was of the request, and the failure was in the render. I was asking "did the server answer" over and over, with increasing thoroughness, when the question was "did the reader see it." Those are different questions, and almost all of our tooling answers the first one.

If you sell ads, go and look at your own site in Brave with Shields on. Not your dashboard. The site.