• Sep 1, 2026
  • 3 min read

Cloudflare Email Obfuscation Fooled My Deploy Check

I changed the legal pages of my consulting site to use a single contact address instead of three (privacy@, legal@ and contact@). After deploying, I ran the check I always run: fetch the live page, count the addresses.

curl -s https://consulting.jalcocertech.com/privacy/ | grep -c '[email protected]'
# 0
curl -s https://consulting.jalcocertech.com/privacy/ | grep -c 'privacy@jalcocertech\|legal@jalcocertech'
# 0

Zero of the old addresses: good. Zero of the new one: impossible — the page clearly showed it in the browser. The “Last updated” date proved the new build was live.

What Cloudflare does to email addresses

Cloudflare’s Email Address Obfuscation (part of Scrape Shield, on by default in many zones) rewrites every address in the HTML it serves. Instead of the address, the page gets something like:

<a href="/cdn-cgi/l/email-protection" class="__cf_email__"
   data-cfemail="2c4f4342584d4f586c...">[email&#160;protected]</a>

A small script on the page decodes it in the browser, so visitors see the real address. Anything that reads the raw HTML — curl, grep, and most email-harvesting scrapers — sees only the encoded string. My check was a scraper, so it saw nothing.

A quick way to spot it:

curl -s https://consulting.jalcocertech.com/privacy/ | grep -o '__cf_email__' | wc -l
# 3

Decoding data-cfemail

The encoding is simple: the hex string’s first byte is a key, and every following byte is a character XOR’ed with that key.

import re, urllib.request

html = urllib.request.urlopen(urllib.request.Request(
    "https://consulting.jalcocertech.com/terms/",
    headers={"User-Agent": "Mozilla/5.0"})).read().decode()

def decode(hexstr: str) -> str:
    key = int(hexstr[:2], 16)
    return "".join(chr(int(hexstr[i:i + 2], 16) ^ key) for i in range(2, len(hexstr), 2))

print(sorted({decode(h) for h in re.findall(r'data-cfemail="([0-9a-f]+)"', html)}))
# ['[email protected]']

(The User-Agent isn’t decoration: the same zones answer Python’s default one with a 403, as I found when error 1010 blocked my own alerts.)

Decoded, the terms page held exactly one distinct address — the new one. The change had worked all along; only the check was wrong.

Feature or bug?

Neither, really. Obfuscation is doing its job: those pages publish a real inbox, and keeping it away from naive harvesters is worth something. I left it on. What changes is how I verify:

  • Check the page the way the platform serves it. Grepping the build output (dist/) proves the source is right; grepping the live page proves what Cloudflare delivers — and those can differ.
  • Grep for __cf_email__ when an address count comes back zero.
  • Decode before concluding, or check in a real browser.

If you’d rather serve addresses untouched (for example, for a page that machines are supposed to read), Email Obfuscation can be turned off per zone in the dashboard, or skipped for specific pages with a Configuration Rule.

Takeaways

  • A zero from grep on a Cloudflare-proxied page may mean “obfuscated”, not “missing”.
  • data-cfemail is a one-byte XOR; ten lines of Python decode it.
  • Decide consciously whether you want obfuscation — it’s a small, free defence against address harvesting.

More gotchas from the same weeks in debugging notes from a week on Cloudflare.