# Cloudflare email-protection breaks screen readers

> A11y audit on the live homepage caught a Cloudflare edge-side trick that source-code audits can't see: the footer email link reads 'email protected' aloud.

**Canonical URL**: https://agentcookbooks.com/blog/cloudflare-email-protection-screen-reader-trap/

**Published**: 2026-05-23

**Tags**: claude-code, a11y

---

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`](/skills/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:

```html
<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:

```html
<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](/blog/cloudflare-ai-audit-robots-txt-trap/) — 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:

```html
<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:

```html
<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.