Skip to main content

Cloudflare email-protection breaks screen readers

A persona pointed at the live homepage and /skills/ index turned up three WCAG 2.2 AA gaps in under ten minutes. Two of them — a missing skip link, a Pagefind input with no SSR accessible name — were findable from source. The third was only visible because the audit ran against the live page: Cloudflare’s edge-side email obfuscation rewrites the footer link so it reads literally “email protected” to screen readers. Nothing in the Astro source shows this. Cloudflare injects it after deploy.

What I ran

accessibility-auditor from msitarzewski/agency-agents, pointed at https://agentcookbooks.com/ and https://agentcookbooks.com/skills/. Scope set up front: WCAG 2.2 AA, static HTML only. No rendered CSS, no interaction, no actual screen reader. The persona’s stated rule is that automation only catches roughly 30% of real issues — the other 70% need a manual screen-reader pass — so flagging the scope as automated-only was the honest start. Color contrast, focus visibility, motion preferences, and keyboard tab order all explicitly off the table.

Honest disclosure on the dispatch: this was persona emulation, not the installed agent. A general-purpose subagent ran with the persona’s framing — WCAG criteria, severity classification, per-finding code-level fix. The 70% of issues that need an actual screen reader on an actual operator’s machine remain untested.

What happened

The persona walked landmarks, heading hierarchy, ARIA labels, link text, and language attributes against both pages’ SSR HTML.

Verified clean on both pages:

  • <html lang="en"> declared, single <h1> with proper h1 → h2 → h3 hierarchy
  • Landmarks present and singular: <header>, <nav aria-label="Primary">, <main id="main">, <footer>
  • Atmosphere decorative div correctly aria-hidden="true"
  • Brand-mark glyph hidden, sibling text accessible
  • Current page indicated via aria-current="page"
  • Zero <img> tags on either page — no missing-alt failures by construction
  • No “click here” / “read more” generic link text
  • <noscript> fallback for Pagefind: “Search needs JavaScript. Use the topic chips below or scroll to the full list.”

Three important findings landed.

1. No skip link to <main id="main">. The id="main" exists in the layout, but no <a href="#main" class="skip-link">Skip to main content</a> as the first focusable element. WCAG 2.4.1 Bypass Blocks. Fix lives in src/layouts/Base.astro.

2. Pagefind search input has no SSR accessible name. The server-rendered markup is:

<div id="ac-pagefind-search" data-skills-count="160"></div>

The actual <input> is JS-injected post-mount. Pagefind UI 1.5.x sets aria-label="Search" by default, so the rendered widget is probably fine — but worth verifying in DevTools and passing explicit translations: { placeholder, search_label } if not. WCAG 4.1.2 Name, Role, Value. A third-party widget concern, not a build-output bug.

3. The Cloudflare email-obfuscation link reads “email protected” to screen readers. This is the receipt. The Astro source emits a plain <a href="mailto:..."> in the footer. Cloudflare rewrites it edge-side to:

<a href="/cdn-cgi/l/email-protection#abc123">
  <span class="__cf_email__" data-cfemail="...">[email protected]</span>
</a>

The __cf_email__ span is decoded client-side by Cloudflare’s email-protection script — email-decode.min.js, injected automatically. To a sighted user with JS, it works: the script swaps the placeholder text for the real address on page load. To a screen reader, the accessible name of the link before (and during) the script run is the literal placeholder text. The user hears the link announced as “email protected, link.” WCAG 2.4.4 Link Purpose (In Context).

Where it drifted

The first two findings are findable from source. A persona reading Base.astro and the Pagefind init block on /skills/ would catch both — the skip-link absence is visible in the layout, the Pagefind SSR shell is in the source.

The Cloudflare finding is not in the source. The Astro build emits a clean mailto:. The obfuscation happens at the edge, in Cloudflare’s response pipeline, after the build artifact has been served. A source-code audit — even an exhaustive one — marks this clean and ships.

The persona caught it because it audited the live HTML, not the source. WebFetch against https://agentcookbooks.com/ returns the post-Cloudflare version, with __cf_email__ already wrapped. Same class of finding as the Cloudflare AI Audit silently rewriting robots.txt — what the CDN injects after the build is invisible to anyone reading the repo.

What I’d change

Three concrete moves.

1. Add a skip link to Base.astro as the first focusable element. Standard pattern:

<a href="#main" class="skip-link">Skip to main content</a>

…with CSS that visually hides it until :focus. Five-minute edit.

2. Disable Cloudflare Email Obfuscation for this zone, or wrap the footer link with an explicit aria-label. Simplest fix: Dashboard → Scrape Shield → toggle off Email Address Obfuscation. The footer email isn’t a spam target worth a screen-reader regression. If keeping the feature on:

<a href="mailto:..." aria-label="Email Agent Cookbooks">
  <span>...</span>
</a>

The aria-label overrides the link’s accessible name; the __cf_email__ placeholder text never reaches the screen reader.

3. Verify the Pagefind input in DevTools post-deploy. If Pagefind UI 1.5.x’s default aria-label="Search" survives, leave it. If it doesn’t, pass explicit translations: { placeholder: "Search 160 skills", search_label: "Search the skills wiki" } in the init block.

The meta-rule the persona produced: live-page audits catch live-page bugs. Source-code audits don’t catch what the CDN injects after the build artifact ships. Both checks are needed; either alone misses a class of finding the other can’t see.