Skip to content
Stribog

Operations

All writing

Cloudflare Email Obfuscation Breaks Next.js Hydration (#418)

Cloudflare rewrites email addresses at the edge, after Next.js has rendered. React hydrates against text it never produced and throws #418 on every page.

Stribog14 min read

We found this on our own site on 6 August 2026, on all 98 pages, on the first day we ever pointed a JavaScript-rendering crawler at it. This is the write-up, including the two wrong turns — one of which would have shipped a fix that did nothing while every gate stayed green.

The underlying interaction between Cloudflare's obfuscation and hydration is not new; it has been reported against Svelte and turns up in Next.js discussions of #418. What we have not seen written down is the part that made it deterministic for us rather than intermittent — a line in robots.txt that Cloudflare's own documentation tells you to add — and what it does to published code samples.

What the crawl found

Screaming Frog SEO Spider 24.3, JavaScript rendering enabled, crawling production. 2,489 internal URLs, 98 of them HTML. Every single one of those 98 pages reported exactly one JavaScript error, and it was the same error on all of them:

text
Error: Minified React error #418; visit
https://react.dev/errors/418?args[]=text&args[]= for the full message
    at rX (https://stribog.com/_next/static/chunks/2g1k9nfc93gb7.js:1:47214)
    at rG (...)
    at sh (...)
    ...
Number of URLs Affected: 98
chrome_console_log_summary_report.csv — one row, 98 occurrences, identical stack.

React error #418 is the minified form of "Hydration failed because the server rendered … didn't match the client. As a result this tree will be regenerated on the client." The args[]=text in the URL is the substituted argument, so the mismatch involves rendered text — but React does not promise a finer taxonomy on that page, and reading it as *only* text and never structure is our inference, not the documentation's.

A second signal pointed the same way, though it is a noisy one worth naming honestly: 17 of the 98 pages had a different word count rendered than raw, the largest gap being +102 words. That is suggestive, not diagnostic. Two pages still show a word-count delta today with zero JavaScript errors, because legitimate client-side rendering also changes word counts. The delta narrows the search; the error text is the evidence.

Why nothing we already ran had ever seen it

The rewrite had been happening for as long as the zone had Scrape Shield on. Everything in our pipeline was green throughout. That is the part worth sitting with, because the gaps are structural rather than accidental:

  • At the time, our end-to-end gates fetched HTML without executing it. They used Playwright's request.get, deliberately — it is fast, stable, and exactly right for asserting what a crawler receives. A page fetched that way never hydrates, so a hydration error cannot occur, let alone be caught. The hydration spec described later in this article did not exist yet; it is the thing we built *because* of this.
  • Lighthouse never saw the error. Not because it cannot: Best Practices includes an errors-in-console audit, and a #418 that actually fires during the run can fail it. It is that Lighthouse drives a normal browser with /cdn-cgi/ allowed, so the decoder repaired the DOM in time and there was nothing in the console to report.
  • The two commercial audit tools we subscribe to do not execute the page. They parse the HTML they are served. To them the page was fine, because at the HTML level it was.
  • A browser usually wins the race. In a real browser the decode script normally loads before hydration, repairs the text in time, and nothing is thrown. The bug is invisible in the exact tool a developer would reach for first.

The mechanism

Cloudflare's Email Address Obfuscation, part of Scrape Shield, is on by default for many zones. It scans HTML as it leaves the edge and does two things to any address it finds:

  1. the mailto: href becomes /cdn-cgi/l/email-protection#<encoded>
  2. the visible text becomes the placeholder [email protected], to be restored on the client by /cdn-cgi/scripts/…/email-decode.min.js

Read step 2 again with a server-rendered framework in mind. Next.js renders the page. The HTML leaves the origin with the correct address in it. Cloudflare then edits that HTML in transit, after the framework is finished and has no idea it happened. The browser receives markup your application never produced, alongside an RSC payload that still describes the original. Hydration compares them and finds a difference.

html
<!-- what Next.js rendered, and what the RSC payload still describes -->
<a href="mailto:hello@example.com">hello@example.com</a>

<!-- the shape the edge substitutes -->
<a href="/cdn-cgi/l/email-protection#7a1f..."
   class="__cf_email__" data-cfemail="7a1f...">[email&#160;protected]</a>
Same URL, two different documents. React only ever knew about the first. This is the markup shape our edge simulation reproduces — we did not capture a production response before turning the feature off, so treat the exact attributes as illustrative.

Why is this usually survivable? Cloudflare documents that email-decode.min.js is injected with defer, downloads in parallel with parsing, and runs after the document is parsed and before DOMContentLoaded. That is the whole of what the vendor says — it schedules nothing relative to React, and does not mention hydration at all. The rest is our observation: on this Next build the framework chunks load with async, so decode and hydration can interleave, and in a normal browser the decoder usually finishes first. Calling it a race is our reading of those observations and of this build's async chunks — not a claim the documentation makes.

The line in robots.txt that removed the race

Step 1 of the rewrite has a well-known side effect. Crawlers see /cdn-cgi/l/email-protection#<encoded>, drop the fragment, request the bare path, and get a 404. Our contact address lives in the footer, so that link was on every page, and the 404 recurred on every page crawled. Our change log for 28 July 2026 records nine such 4xx responses in Bing Webmaster Tools over the preceding two days; we did not retain that export alongside the August crawls, so treat the figure as our own operational note rather than as attached evidence.

The remedy is documented, standard, and recommended by Cloudflare itself: the `/cdn-cgi/` endpoint reference states as a best practice that you should add Disallow: /cdn-cgi/ to robots.txt, precisely because crawlers otherwise hit errors on infrastructure paths that are not your content. So we did:

text
User-Agent: *
Allow: /
Disallow: /cdn-cgi/
Sitemap: https://stribog.com/sitemap.xml
Correct advice, correctly applied. It is also the load-bearing half of the bug.

/cdn-cgi/scripts/…/email-decode.min.js is under /cdn-cgi/. A robots-respecting renderer will not fetch it — our own crawl confirms it, logging that script as blocked by robots.txt. Googlebot is a robots-respecting renderer. So for any client that renders the page and obeys robots, the decoder never runs, the placeholder survives into hydration, and the mismatch stops being a race. It becomes a certainty. We added that Disallow on 28 July 2026; before it, the same rewrite was in place but the failure was intermittent rather than reliable.

The documentation does carry one caveat about that blanket Disallow, and it is worth reading for what it does *not* say. If you serve image transformations, blocking /cdn-cgi/ also blocks /cdn-cgi/image/, so you are told to add a more specific Allow first. That caveat exists because someone filed it against the docs. The decode script sits in the same directory and is at least as load-bearing, and nobody has filed that one.

We confirmed the causal direction rather than inferring it, by fetching the same production page twice with the decoder allowed and then blocked. Decoder allowed: no error. Decoder blocked: #418. That is the whole hypothesis, tested both ways, in two requests.

The fix — and the marker I invented

Cloudflare documents an escape hatch: content wrapped in a marker comment pair is left alone. React cannot emit raw comment nodes from JSX, so this needs dangerouslySetInnerHTML over a build-time constant:

tsx
export function ContactEmail({ address, href, className }: ContactEmailProps) {
  const email = escapeHtml(address ?? siteConfig.contactEmail);
  const target = escapeHtml(href ?? `mailto:${address ?? siteConfig.contactEmail}`);
  const attrs = [
    `href="${target}"`,
    className ? `class="${escapeHtml(className)}"` : "",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <span
      dangerouslySetInnerHTML={{
        __html: `<!--email_off--><a ${attrs}>${email}</a><!--/email_off-->`,
      }}
    />
  );
}
Abridged from the shipped component — the real one also takes an aria-label. Every interpolated value goes through escapeHtml, and optional attributes are omitted rather than emitted empty; build the string the lazy way and an absent className renders as class="undefined".

The first version of this component closed the pair with <!--email_on-->. I did not read that in Cloudflare's documentation. I inferred it, because email_off implies an email_on, and it looks entirely plausible sitting in a diff. The documented pair is <!--email_off--> and <!--/email_off-->.

An invented closing marker would have shipped, done precisely nothing at the edge, and left every gate green — because the end-to-end test that simulated Cloudflare honoured the same marker I had invented. The test and the code shared one false assumption, so they agreed with each other perfectly. An adversarial review caught it and the documentation settled it.

The test that could not fail

Cloudflare is not in front of a local dev server, so reproducing this in CI means intercepting the HTML response and applying the transformation ourselves. Getting that faithful took three attempts. The first two produced results that were worse than no test at all.

  1. It rewrote inside <script> too. That corrupted Next's inlined RSC payload to *agree* with the corrupted DOM. Hydration matched, so two separate deliberate mutations of the component both passed. A green test that was structurally incapable of failing.
  2. It rewrote attribute values too. The contact form's placeholder attribute holds a specimen address, so the helper mangled it and the test went red — a failure with no bug behind it, which erodes trust in a gate just as fast as a false pass.
  3. Text nodes and mailto: hrefs only, skipping <script> and every other attribute. Not a reimplementation of Scrape Shield — Cloudflare also injects the decode script and has its own skip list — but faithful enough to fail on the mutations we care about, which is the bar a simulation has to clear.

Fixing the fidelity then falsified something we thought we had proven. With dangerouslySetInnerHTML, the address is no longer a React-reconciled child at all — so removing the markers stops producing #418. The two halves of the fix do different jobs, and each needs its own assertion: the raw-HTML span prevents the hydration error, and the email_off markers keep the real address on screen for a renderer that cannot run the decoder. Without the markers, no error is thrown and Googlebot simply reads the string [email protected] as your contact address.

Both are mutation-verified. Render the address as a plain React child: the hydration assertion fails on four pages. Keep the raw HTML but drop the markers: hydration passes, the visible-address assertion fails. Restore both: 12 of 12 pass. A test you have not watched fail is a test you have not written.

98 → 12: the residue was published source code

Deploy, re-crawl. 98 pages down to 12. All 12 were blog articles — and the residue does not sort into one clean category, which turns out to be the interesting part:

  • Seven carry a plain …@example.com address in a code sample — the obvious case, and the one the next section is about.
  • One carries a Google service-account address, releasemgr-svc@…iam.gserviceaccount.com. Also a real address, just not one a human reads.
  • One carries an AMQP connection string, password@rabbitmq.batch.svc. Not an address at all — but .svc sits where a TLD would.
  • One carries WebAssembly interface pins, incoming-handler@0.2.3. Address-shaped only if your pattern is loose.
  • Two carry nothing address-shaped by any reading — only GitHub Action pins like checkout@v5 and generator_container_slsa3.yml@v2.0.0.

Where you draw the line between "address" and "version pin" moves that count, and we cannot settle it from the outside: Cloudflare does not publish the pattern, and we destroyed our own evidence by turning the feature off, which cleared all twelve at once. The safe conclusion is the uncomfortable one — the matcher's scope is wider than "email addresses" and we do not know its edges. If you publish service@host strings of any kind, that is your exposure too.

Take the unambiguous case. Cloudflare rewrites addresses it finds in your HTML, and it does not know that some of that HTML is a <pre><code> block containing a Kubernetes manifest. Our article on multi-tenancy carries a Capsule Tenant sample. Here is what ends up in the DOM of any client that does not run the decoder:

text
spec:
  owners:
    - name: [email protected]
      kind: User
Not YAML any more — the placeholder parses as a flow sequence, not a string. Apply that to a cluster and it does not do what the article says.
yaml
spec:
  owners:
    - name: team-a-lead@example.com
      kind: User
What we wrote, and what the page shows today.

Be precise about who this hurts, because the mechanism cuts both ways. A reader in an ordinary browser usually gets the decoded address and never notices — the same race that hid the bug protects them. The ones who get the placeholder are the clients that render while obeying robots.txt, anyone whose browser loses the race, and anything reading the page without executing scripts. That set includes the AI assistants people increasingly ask to summarise a page, and it is the set that ingests your content wholesale. A hydration warning costs a re-render; a corrupted manifest that reaches a reader costs their afternoon. Both are invisible to the author, because the author's browser repairs the page in front of them. The affected set included our articles on the Cyber Resilience Act, supply-chain security and internal PKI — every one of them a page whose whole value is that you can copy the snippet.

12 → 0: turn the feature off

There is no marker you can put around a code block that a content pipeline will reliably preserve, and wrapping every future snippet in HTML comments to protect it from your own CDN is not a maintainable position. Email Address Obfuscation buys protection against address-scraping bots. We publish exactly one contact address, on a domain whose MX has spam filtering in front of it. The trade was not close.

Turned off zone-wide. Re-crawled, JavaScript rendering on: 1,926 internal URLs, of which 99 are HTML pages and every one of those returned HTTP 200. Zero pages with JavaScript errors, zero console entries. The Disallow: /cdn-cgi/ line stays — it is still correct advice, and with no rewrite happening there are no /cdn-cgi/l/email-protection links left to 404 in the first place. The ContactEmail component stays too, markers and all: it costs nothing and it means the next person who enables Scrape Shield does not silently reintroduce this.

text
2026-08-06  js-rendered         98 HTML pages   98 with React #418
2026-08-06  js-postfix          98 HTML pages   12 with React #418
2026-08-06  js-obfuscation-off  99 HTML pages    0 with React #418
Three crawls, one day. The middle row is the fix; the last row is the decision.

One counting note, since the whole article is an argument for measuring things properly. That last row is a zero from a crawl that fetched 1,926 URLs — check the page count before you believe a zero, because a crawl that fetched nothing produces an identical-looking result. And do not count rows in these exports with wc -l: the console-summary CSV embeds newlines inside a quoted field, so wc -l reports eleven lines for a header plus one logical finding. Parse the CSV.

How to check your own site in five minutes

  1. Is the rewrite happening at all? Fetch a page that shows an address and look for Cloudflare's markers in the delivered HTML.
  2. Is the decoder disallowed? Check robots.txt for Disallow: /cdn-cgi/. Both true means the mismatch is deterministic for robots-respecting renderers.
  3. Confirm it end to end. Load the page in a headless browser with /cdn-cgi/* blocked and watch the console. This is what a rendering crawler sees.
  4. Then decide. Wrap the addresses in <!--email_off--><!--/email_off-->, or turn Email Address Obfuscation off. If you publish code samples, the second option is the only one that scales.
bash
# 1. is the edge rewriting your address?
curl -s https://example.com/ | grep -c '__cf_email__\|cdn-cgi/l/email-protection'

# 2. did you tell robots not to fetch the decoder?
curl -s https://example.com/robots.txt | grep -i 'cdn-cgi'
Two non-zero counts together are the whole diagnosis.

None of this depends on our stack or our scale. Any application that server-renders and then hydrates — React, Svelte, or anything else — sitting behind a Cloudflare zone with Email Address Obfuscation on is exposed to this class of mismatch, and adding Disallow: /cdn-cgi/ is what turns exposure into reliability. We have no measurement of how often #418 in the wild has this cause, and we are not going to guess at one. What we can say is that the failure is silent in browsers, invisible to non-rendering audit tools, and hardest on the clients that render your pages while obeying your robots.txt.

What this does not prove

  • That your zone behaves identically. Edge behaviour is observable only in production. Check yours; do not take ours as a specification.
  • That #418 harms rankings. We have no evidence either way, and we are not going to invent some. What is demonstrable is that the rendered document differed from the served document, and that published code samples were corrupted — both worth fixing on their own terms.
  • That the email_off markers work at the edge. This is the weakest link in the chain and it deserves naming plainly. The middle crawl was taken with the component deployed *and obfuscation still on*, so 98 → 12 is consistent with dangerouslySetInnerHTML removing the reconciled text node all by itself; it does not isolate whether Cloudflare honoured the markers. Proving that needs a rendered crawl with obfuscation on and the markers as the only variable, which we did not run because we turned the feature off instead. They are retained as a belt-and-braces measure on an assumption we never isolated in production; the e2e gate states that limit in its own header, and the markers stay because they cost nothing if someone re-enables Scrape Shield.

The generalisable lesson is smaller and duller than the bug: your CDN is a participant in your rendering pipeline, not a pipe. Anything that rewrites HTML in transit — email obfuscation, script injection, HTML minification, automatic image rewriting, analytics beacons — is editing a document your framework believes it controls. If you server-render and then hydrate, every one of those features is a candidate hydration mismatch, and no test that stops at the origin will ever tell you.

§FAQ/Common questions

Frequently asked

What causes React error #418 on a Next.js site behind Cloudflare?

One candidate worth eliminating early is Cloudflare's Email Address Obfuscation, part of Scrape Shield — we have no data on how common a cause it is, but it is cheap to rule in or out. It rewrites any email address it finds in your HTML at the edge, after Next.js has rendered the page: the mailto href becomes a /cdn-cgi/l/email-protection link and the visible text becomes a [email protected] placeholder, restored on the client by a decode script under /cdn-cgi/. React then hydrates against text it never produced and throws #418 — the minified form of "Hydration failed because the server rendered … didn't match the client", with text as the substituted argument. Cloudflare injects that decode script with defer, and in an ordinary browser it usually runs before hydration, so the error is intermittent or absent — which is why this hides so well. Cloudflare's documentation describes the script's scheduling but says nothing about React; the ordering is something you observe, not something the vendor guarantees. If your robots.txt also carries Disallow: /cdn-cgi/, a robots-respecting renderer such as Googlebot never fetches the decoder at all and the mismatch becomes deterministic.

Should I remove Disallow: /cdn-cgi/ from robots.txt to fix it?

No. Cloudflare's own /cdn-cgi/ endpoint documentation recommends that Disallow as a best practice, because crawlers otherwise hit errors on infrastructure paths that are not your content — in our case a 404 on every page crawled, which Bing Webmaster Tools duly recorded. Removing it trades a rendering bug for a crawl-error problem and leaves the underlying rewrite in place. Fix the rewrite instead: wrap addresses in Cloudflare's documented email_off markers, or turn Email Address Obfuscation off. One caveat if you serve image transformations: a blanket Disallow: /cdn-cgi/ also blocks /cdn-cgi/image/, so add a more specific Allow rule before it.

What are the correct Cloudflare email_off markers?

The documented pair is <!--email_off--> to open and <!--/email_off--> to close, with a forward slash on the closing marker, exactly like an HTML tag. <!--email_on--> is not a Cloudflare marker; we invented it by inference and it would have shipped inert. Verify the strings against Cloudflare's Email Address Obfuscation page rather than against intuition, and cite that URL in whatever test pins them — an invented marker honoured by your own test suite produces a fix that does nothing and a CI run that stays green. In React you cannot emit raw comment nodes from JSX, so applying these markers requires dangerouslySetInnerHTML over a build-time constant, with every interpolation escaped.

Why did our end-to-end tests and Lighthouse not catch this?

Because none of them executes the page the way a robots-respecting rendering crawler does. End-to-end suites that assert on served HTML commonly fetch it with a plain request rather than a browser — fast, stable, and correct for what they check — but a page fetched that way never hydrates, so a hydration error cannot occur. Lighthouse is the interesting case: its Best Practices category does include an errors-in-console audit, so a hydration error that actually fires during the run can fail it. It did not fire, because Lighthouse drives an ordinary browser that is allowed to fetch the decode script, which repairs the DOM before hydration. Audit tools that parse HTML without rendering see a document that is internally fine. And a developer checking in a real browser has the same protection. The only configuration that reliably exposes it is a JavaScript-rendering crawl that obeys robots.txt, which is what finally found it here.

Does Cloudflare rewrite email addresses inside code blocks?

Yes. The rewrite operates on the HTML document, not on semantic context, so an address inside <pre><code> is treated exactly like an address in a footer. On our site that turned a Capsule Tenant manifest's owners entry from team-a-lead@example.com into the string [email protected], which no longer parses as the string it should. Twelve of our articles were affected. Seven carry a plain example address; the rest carry things that merely resemble one — a Google service account, an AMQP connection string ending .svc, WebAssembly interface pins, and two pages with nothing address-shaped by any reading, only GitHub Action pins. We could not establish what the matcher accepted on those last few, because disabling the feature cleared all twelve and removed the evidence. The practical reading is that the matcher's scope is wider than email addresses and its edges are not published. The corruption is also invisible to the author, since the decode script repairs it in the author's own browser. If you publish configuration samples, that is a strong argument for disabling the feature rather than wrapping every future snippet in marker comments.

cloudflare email obfuscationreact hydration error 418next.js hydration mismatchemail-decode.min.jscdn-cgi robots.txt disallowjavascript rendering crawl

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.