Cloudflare Email Obfuscation Silently Rewrites @scope/pkg@version in Your Published HTML
📅 July 31, 2026 · ⏱️ 14 min read
There is a category of bug that no local test can find, because the bug is not in your output. It is in what happens to your output after it leaves you.
This site shipped one. A post about video encoding quoted three pinned npm identifiers — the scoped-package-plus-version form, @ffmpeg/core@0.12.10 and its two siblings — inside <code> elements, mid-prose. The build was correct. The bytes on disk were correct. Every test passed. And the page a reader loaded had those three identifiers replaced with an obfuscated placeholder and a link to a mail-protection stub, because Cloudflare’s Email Address Obfuscation matched them as email addresses and rewrote them at the edge.
Everything below was measured on 2026-07-31 and re-measured on 2026-08-28. Where a number would go stale the moment anything else on the site changed, it has been replaced by the quantity that does not — for the reason in the shelf-life section.
Why a package pin looks like an email address
Email Address Obfuscation is a zone-level Cloudflare feature that is on by default. It scans HTML responses as they pass through the edge, finds text that looks like an email address, and replaces it with a placeholder plus a small script that reassembles the real address in the browser. The intent is anti-scraping, and for a mailto: on a contact page it works exactly as advertised.
The scanner does not know what a <code> element is. It knows what an at-sign between two word-shaped runs looks like. And so does this:
@scope/pkg@1.2.3
A scoped package with a pinned version has a local part (scope/pkg), an at-sign, and a dotted right-hand side (1.2.3) that is indistinguishable from a domain. It gets rewritten. The reader with JavaScript enabled sees the placeholder replaced back into something — but not your token; the script restores the string the scanner decoded, and the surrounding markup is now an anchor tag it invented. The reader without JavaScript — an RSS client, a text-mode crawler, a scraper, an LLM ingesting raw HTML — sees [email protected] where your version pin used to be, and cannot follow the argument at all.
Search for this problem and you find two neighbouring conversations: pagespeed writeups complaining about the extra edge script, and framework threads where the injected anchor breaks hydration or where a legitimate mailto: stops working. The half that nobody seems to have written down is the one that cost us: the feature silently changes the content of your page, and it does it in the one place your CI cannot look.
The byte accounting
The fix has shipped, so the numbers below are the residual — what the edge still does to a page that has been protected. That residual is the interesting part, because it is small, constant, and fully explained.
Same page, same minute: what we built, versus what came back over the wire. Subtract the two — and only the subtraction is quoted here, deliberately, because the two operands do not hold still:
$ built=$(wc -c < dist/Blog/<post>/index.html)
$ live=$(curl -s "$SITE/Blog/<post>" | wc -c)
$ echo $(( live - built ))
-48
A delta of -48 bytes. Not 47, not “about 50” — the live page is smaller than the built page by exactly 48 bytes. That figure has survived every run of the probe so far, at two different absolute sizes for that same page (it gained 35 bytes when this post was published; the delta did not move a byte). Here is where they go:
$ OFF='<!--email_off-->'; ON='<!--email_on-->'
$ grep -o "$OFF" dist/Blog/<post>/index.html | wc -l # 3
$ curl -s "$SITE/Blog/<post>" | grep -o "$OFF" | wc -l # 0
$ curl -s "$SITE/Blog/<post>" | grep -o "$ON" | wc -l # 3
$ printf %s "$OFF" | wc -c # 16
The edge consumes each opening marker and leaves each closing marker in place. Three markers at 16 bytes each is 48 bytes, and 48 bytes is the entire delta. Nothing else on that page changed.
The marker count that matters most went the other way:
$ curl -s "$SITE/Blog/<post>" | grep -o '__cf_email__' | wc -l # 0
$ curl -s "$SITE/Blog/<post>" | grep -o 'ffmpeg/core@0' | wc -l # 3
Zero obfuscated spans, and the three pinned identifiers in that page’s body — each a placeholder before the fix — reach a no-JavaScript reader as literal text. Three counts only that post’s body; the substring total also counts its frontmatter, as this paragraph once learned.
The probe runs the same comparison across every URL in the sitemap plus the feed. The count of those rows is not a fact about the edge — it is a fact about how many posts exist this week, and it goes up whenever anyone publishes. So the claim worth making is the one that does not move with the inventory: every differing row’s delta is fully accounted for by consumed markers at 16 bytes each, and every other row is byte-identical. Unexplained deltas: zero. That has held on every run.
(The dated snapshot, if you want one: on 2026-07-31, after this post went live, 25 rows — 22 byte-identical, 3 differing. It read 24 rows and 2 differing a day earlier, and it will read something else again next week, which is the whole point of the next section.)
The byte counts in this post falsified themselves
This post shipped with three absolute byte counts in it. Two of them were true when I measured them and false by the time anyone could read them, and the reason is more useful than the digits were.
Every post page on this site ends with a prev/next navigation module that embeds the neighbouring post’s slug and title. That slot moves with publication order:
$ grep -c 'cloudflare-email-obfuscation' dist/Blog/webcodecs-instead-of-ffmpeg-wasm/index.html
0
$ grep -o 'cloudflare-email-obfuscation' dist/Blog/webcodecs-instead-of-ffmpeg-wasm/index.html | wc -l
0
Both printed 1 on 2026-07-31 and 0 on 2026-08-28; a newer post took the slot. Both agreements were luck: the file is one line, so grep -c would have said 1 even if there were nine. That is the trap the last section is about; I ran both on purpose.
One link is enough to ruin an absolute. On a site with related-post links, a page’s byte size is a function of the entire post inventory, not of that page’s own source. The single commit that published this post inserted a ”← Newer” entry pointing at it into the page it is about, and that page went from 23,879 bytes to 23,914 — the build immediately before that commit and the build immediately after, both re-read from git on 2026-07-31. Those two absolutes are safe to print only because they are pinned to a before and an after rather than offered as the present tense; quoted bare, either one is wrong within a week. The blog index gained a card in the same commit. The feed gained an item. All three sizes had been written into this post as bare present-tense facts, and publishing the post is what made two of them false.
A delta does not have that problem. The edge consumes each opening marker whatever the page around it weighs, so -48 survived a 35-byte change in the page it describes without moving, and 16 × markers-consumed will keep predicting it after the next ten posts land. The rule this post now follows: quote the quantity produced by the transformation you are describing, and if you must quote an absolute, put a date and a reason next to it. An absolute byte count of your own page has a shelf life of one publication.
The rule you can act on: the obfuscator wants a dot after the at-sign
This is the part worth taking away, and it is testable on your own site in about a minute.
The same post’s excerpt: frontmatter contains the bare token @ffmpeg/core — scope and package, no version. On 2026-08-28 it reaches five surfaces: og:description, twitter:description, the JSON-LD blob, the card on the blog index, and the <description> of the RSS item — the sixth, <meta name="description">, having gone to a metaDescription: override. Every one of those is served through the same edge as the post body.
$ curl -s "$SITE/Blog" | grep -o '__cf_email__' | wc -l # 0
$ curl -s "$SITE/feed.xml" | grep -o '__cf_email__' | wc -l # 0
Both of those pages come back byte-identical built-vs-live — a delta of exactly 0, which is the number that matters and the only one that will still be true next month. (Their absolute sizes are not quoted here; both of them changed the day this post was published, for the reason in the section above.) The bare token is not touched, in any of those surfaces, in either file.
So the discriminator is not “contains an at-sign” and it is not “is inside a code element.” It is the shape of the right-hand side:
- The bare form
@scope/pkg— safe. Nothing after the at-sign that looks like a domain, because there is no dot. - The pinned form
@scope/pkg@1.2.3— rewritten.1.2.3has dots, so it parses as a hostname.
Which generalises past npm. Anything of the form word@word.word in your prose is a candidate: a Docker tag with a dotted digest label, user@host.tld in an SSH example, a Maven coordinate, a Git ref that happens to contain a dotted version. If it has a dot on the right of the at-sign, assume it will be rewritten.
Cloudflare is right about the actual email addresses
It would be easy to write this as a vendor complaint, and that would be dishonest, because on the same site the same feature does exactly the job it was built for. The privacy page carries two genuine contact addresses:
$ wc -c < dist/Privacy/index.html # 9726
$ curl -s "$SITE/Privacy" | wc -c # 10104
$ curl -s "$SITE/Privacy" | grep -o '__cf_email__' | wc -l # 2
+378 bytes, two obfuscated spans, two real addresses protected from the naive scrapers. That is the feature working. The failure mode is not that Cloudflare obfuscates email addresses; it is that “looks like an email address” is a lexical test applied to a document whose author knows things the lexer does not.
Those two absolute sizes are quoted, and they were measured on 2026-07-31. They are the one pair in this post that is reasonably safe as an absolute, and it is worth knowing why: grep -c 'post-nav' dist/Privacy/index.html returns 0, and the page links to no individual blog post at all, so nothing anyone publishes moves it. Every post page carries that nav and therefore does drift. The +378 is still the durable half of the row.
The fix, on the content side
You do not need to reach for a dashboard toggle or an API call to fix this, and turning the feature off site-wide would give up the protection above. The documented content-side opt-out is a marker pair around the region you want left alone:
<!--email_off--> … <!--email_on-->
Wrap the token, rebuild, done. The edge honours it, at a cost of the 16 bytes per marker measured earlier. The alternative, if a comment is awkward where you need it, is to write the at-sign as the numeric character reference @, which renders and copies as a literal @ but no longer matches the scanner’s pattern. Both work; the marker is easier to grep for later.
This post is itself wall-to-wall email-shaped tokens, so every version pin in it is wrapped in that marker pair, which is either tidy or unbearably smug depending on your mood. It is therefore a differing row in its own probe: four protected regions, four opening markers consumed at the edge, four closing markers left behind, -64 bytes. Same arithmetic, different multiplier — which is the point of stating the arithmetic instead of the sizes.
The durable version of the fix is not the wrap, though — it is a test that fails when someone writes an unprotected token in a new post. Ours walks every markdown source file, matches the email-shaped pattern, and checks that each hit falls inside a marker region or uses the numeric entity. It also asserts the markers are balanced, because an unclosed opening marker silently disables obfuscation for the rest of the document — which would quietly expose a real address if one ever appeared below it.
The same lesson wearing a different hat: grep -c counts lines
Within the same week, three separate checks on this estate reported false numbers, and the root cause is the same sentence as the one above.
grep -c does not count occurrences. It counts matching lines. Generated output — minified HTML, an XML sitemap, an RSS feed — is very often one single line. Watch:
$ wc -l < feed.xml
0
$ grep -c '<item>' feed.xml
1
$ grep -o '<item>' feed.xml | wc -l
13
Zero newlines, one “matching line”, thirteen actual items — twelve blog posts and one electronics writeup, at the moment I ran it. I predicted in the draft that publishing this post would make it fourteen and that grep -c would still say 1. Re-run on 2026-07-31, after publication: grep -o '<item>' | wc -l says 14, grep -c says 1. The same file gave a sitemap check the same lie on that first run: grep -c '<loc>' printed 1 against 23 real URL entries — 24 of them on 2026-07-31, because publishing this post moved that number too, and the miscount did not care either way.
The two neighbouring failures were subtler and both survived review:
$ grep -c '^<item>' feed.xml
0
$ grep -o -E '<item>.{0,80}</item>' feed.xml | wc -l
0
A line-anchored pattern matches nothing at all, because there is exactly one line and it does not start with the tag. And a bounded quantifier — the reflex when you want a non-greedy match in basic grep — matches nothing either, because real items are longer than the bound you guessed, and the failure presents as a clean, confident 0.
Three checks, three false numbers, all of them landing in a summary that a human then read and believed. Nothing crashed. That is the dangerous shape: a miscount does not throw.
The one sentence both findings share
You cannot audit generated or edge-transformed output with the tools you use on source.
Source is line-structured, hand-written, and identical to what you committed. Generated output is one long line. Delivered output is not even yours any more — it has been through a transform you did not write and cannot run locally. Both bugs came from applying a source-shaped tool to one of those.
The technique that catches the whole class is not clever, and it is the actual payload of this post:
- Build.
- Enumerate your own URLs from the artifact you just built — the sitemap is right there.
- Fetch each one over the public internet, as an anonymous client, with no cookies and no cache.
- Compare byte length against the built file, and diff the two on any mismatch.
- Count with
grep -o … | wc -l, or parse the document properly. Nevergrep -con generated markup.
Then make the runner explain every non-zero delta, and treat “explained” as the pass condition rather than “zero”. A delta of zero is not the goal; an unexplained delta is the alarm. Ours prints, per differing URL, which edge markers appeared or vanished, which is how -48 stopped being a mystery and became “three consumed comment markers, 16 bytes each.”
Honest limits
This is a small, specific finding and it deserves a small, specific claim.
- The numbers above describe one static site behind one CDN feature. The 48-byte delta is a consequence of our three markers on that page; yours will differ. The reproducible part is the method, not the constants.
- Byte-length comparison catches insertion and deletion. It does not catch a transform that preserves length, and it does not tell you the page renders correctly — a page can be byte-perfect and still broken. This replaces nothing you already do; it covers a gap that nothing else was covering.
- Fetching every URL on every build costs you a round trip per page and will be flaky on a bad network. Ours runs against about two dozen URLs and takes a few seconds; at a few thousand pages you would sample instead.
- The marker pair is a Cloudflare-specific escape hatch. On another CDN it is inert text, harmless but useless, and the numeric-entity form is the more portable of the two.
- Most importantly: the check only pays off if something outside your build is allowed to change the bytes. If nothing sits between your artifact and the reader, you already have the guarantee this buys.
The mangled identifiers had been live and wrong for weeks. Nothing in the repository was capable of noticing, because everything in the repository was correct. The only way to find out what your readers get is to go and be one.
More build notes and postmortems: the DankDev blog. The measurement work that turned this up started with what breaks when a coding agent runs unattended, and the post that got mangled is why we dropped ffmpeg.wasm for WebCodecs.
— DankDev
Tags: Cloudflare · npm · Debugging · Static Sites · Measurement
Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.