WordPress shield from bots

How to Protect Email Addresses in WordPress with a Secure Proxy (No Plugin Needed)

In a previous article, the 6 Proven Ways to Mask Email on Your Website and Stop Spam Bots, I covered a range of techniques for protecting email addresses from scrapers, from basic HTML entity encoding all the way to the email proxy token method. That comparison made one thing clear: the proxy approach is the strongest option available, and it deserves a full implementation guide of its own.

This is that guide.

Every website that publicly displays an email address is a target for spam bots. These automated crawlers scan HTML source code, extract anything that looks like an email address, and feed it into spam lists within hours of publication to data brokers. As I outlined in that article, weaker methods like entity encoding or JavaScript decoding either leave the mailto: address exposed.

The proxy method solves both problems at once.

This tutorial walks you through a complete server-side email proxy built entirely in PHP and WordPress. No plugin required, no third-party services, and no JavaScript dependencies. The technique uses single-use cryptographic tokens stored as WordPress transients. First, it intercepts every email click and validates it server-side. Then, it redirects only legitimate users to the mailto: address.

By the end, your email addresses will never appear in page source, HTML attributes, or JavaScript. Yet clicking an email link will open the visitor’s mail client exactly as expected.

Table of Contents

  1. How the Email Proxy Works
  2. Prerequisites
  3. [Step 1] The Token Generator Functions
  4. [Step 2] The Proxy Endpoint File
  5. [Step 3] Using the Proxy in Templates
  6. [Step 4] Obfuscating Visible Email Text
  7. [Step 5] Handling Mailto Links Set by Admins
  8. [OPTIONAL] Auto-Replace Mailto Links in Content
  9. Security Features Explained
  10. Testing Your Implementation
  11. Extra Protection: Block in robots.txt
  12. FAQs

1. How the Email Proxy Works

The flow has five stages:

Page Load → Token Generated → Token Stored (1 hr TTL) → Link Written With Token URL
           ↓
User Clicks Link → Proxy File Validates Token → Referrer & Bot Checks → Rate Limit Check
           ↓
Token Deleted (single-use) → 302 Redirect to mailto:address

The actual email address is never written to the HTML. Every link points to email-proxy.php?token=<32_char-random_string>. Only the server knows which token maps to which address, and each token is destroyed the moment it is used.

2. Prerequisites

  • A self-hosted WordPress installation (version: 6+)
  • Access to your active theme’s folder
  • Basic familiarity with PHP and WordPress theme template files

No additional libraries or plugins are needed. The system relies entirely on:

  • wp_generate_password() – cryptographically secure random string generation
  • set_transient() / get_transient() / delete_transient() – WordPress’s built-in key-value cache
  • wp_die() – safe error termination
  • Standard PHP headers for the redirect

3. [Step 1] The Token Generator Functions

3.1 Generate a Single-Use Token

/**
 * Generates a cryptographically secure single-use token for an email address.
 * The token is stored as a WordPress transient with a 1-hour TTL(Time To Live/Lifespan).
 *
 * @param string $email The email address to protect.
 * @return string A 32-character alphanumeric token.
 */
function mytheme_generate_email_token( string $email ): string {
    $token = wp_generate_password( 32, false, false );

    set_transient(
        'email_token_' . $token,
        [
            'email'   => $email,
            'created' => time(),
        ],
        HOUR_IN_SECONDS
    );

    return $token;
}

Why wp_generate_password(32, false, false)?

  • 32 characters of entropy – brute-forcing the token space is computationally infeasible.
  • The second argument (false) disables special characters so the token is URL-safe without encoding.
  • The third argument (false) disables extra special characters.

Why WordPress transients?

Typically, transients use the database. However, if an object cache like Redis or Memcached is installed, they will use that instead. They natively support expiration, which means:

  • Tokens expire automatically after 1 hour, even if never used.
  • There is no orphaned data to clean up manually.
  • The HOUR_IN_SECONDS constant (3600) is defined by WordPress core.

3.2 Build the Proxy URL

/**
 * Returns a secure proxy URL for a given email address.
 * Use this wherever you would otherwise write href="mailto:...".
 *
 * @param string $email The email address to protect.
 * @return string A proxy URL, or '#' if $email is empty.
 */
function mytheme_get_email_proxy_url( string $email ): string {
    if ( empty( $email ) ) {
        return '#';
    }

    $token     = mytheme_generate_email_token( $email );
    $proxy_url = get_template_directory_uri() . '/email-proxy.php?token=' . $token;

    return $proxy_url;
}

Note: get_template_directory_uri() returns the URL to your active theme folder. If you are building a child theme and placing the proxy file in the child theme, use get_stylesheet_directory_uri() instead.

3.3 Obfuscate Visible Email Text (Optional)

When the email address itself is the visible anchor text (e.g., Click [email protected] to reach us), you need to obfuscate the rendered characters too. The function below converts each character to an HTML decimal entity and randomly sprinkles in empty HTML comments to break regex scanners:

/**
 * Obfuscates an email address for safe display in HTML.
 * Converts each character to its decimal HTML entity and optionally
 * inserts random HTML comments between characters.
 *
 * @param string $email        The email address to obfuscate.
 * @param bool   $use_comments Whether to inject random HTML comments. Default true.
 * @return string Obfuscated HTML-safe string.
 */
function mytheme_obfuscate_email_text( string $email, bool $use_comments = true ): string {
    if ( empty( $email ) ) {
        return '';
    }

    $obfuscated = '';
    $length     = strlen( $email );
    $comments   = [ '<!-- x -->', '<!-- -->', '<!-- z -->', '<!-- . -->' ];

    for ( $i = 0; $i < $length; $i++ ) {
        $obfuscated .= '&#' . ord( $email[ $i ] ) . ';';

        if ( $use_comments && $i < $length - 1 && rand( 0, 2 ) === 0 ) {
            $obfuscated .= $comments[ array_rand( $comments ) ];
        }
    }

    return $obfuscated;
}

The output renders perfectly in a browser (&#106;&#111;&#104;&#110;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;) but is meaningless to a simple regex harvester. Combined with the proxy URL on the href, both attack vectors are addressed.

4. [Step 2] The Proxy Endpoint File

Create a new file in the root of your theme folder named email-proxy.php. This is the file all email links point to.

<?php
/**
 * Email Proxy: Secure mailto redirect with token validation.
 *
 * Every email link on the site points here as:
 *   email-proxy.php?token=<32-char-token>
 *
 * The file validates the token, checks for bots and rate limits,
 * then issues a 302 redirect to the real mailto: URI.
 */

// Bootstrap WordPress so we can use its functions and database.
require_once '../../../wp-load.php';

// ── 1. Token presence check ────────────────────────────────────────────────
if ( empty( $_GET['token'] ) ) {
    wp_die( 'Invalid request', 'Error', [ 'response' => 403 ] );
}

$token = sanitize_text_field( $_GET['token'] );

// ── 2. Token validity check ────────────────────────────────────────────────
$email_data = get_transient( 'email_token_' . $token );

if ( ! $email_data ) {
    wp_die( 'Invalid or expired token', 'Error', [ 'response' => 403 ] );
}

// ── 3. Referrer check ─────────────────────────────────────────────────────
$referrer = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '';

if ( empty( $referrer ) || strpos( $referrer, home_url() ) !== 0 ) {
    error_log( 'Email proxy: invalid referrer — token: ' . $token . ', referrer: ' . $referrer );
    wp_die( 'Invalid referrer', 'Error', [ 'response' => 403 ] );
}

// ── 4. Bot user-agent check ────────────────────────────────────────────────
$user_agent    = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
$bot_patterns  = [ 'bot', 'crawl', 'spider', 'scrape', 'harvest' ];

if ( empty( $user_agent ) ) {
    wp_die( 'Invalid request', 'Error', [ 'response' => 403 ] );
}

foreach ( $bot_patterns as $pattern ) {
    if ( stripos( $user_agent, $pattern ) !== false ) {
        error_log( 'Email proxy: bot detected — UA: ' . $user_agent );
        wp_die( 'Invalid request', 'Error', [ 'response' => 403 ] );
    }
}

// ── 5. Rate limiting (10 requests per IP per minute) ──────────────────────
$ip_address      = $_SERVER['REMOTE_ADDR'];
$rate_limit_key  = 'email_proxy_rate_' . md5( $ip_address );
$request_count   = get_transient( $rate_limit_key );

if ( $request_count === false ) {
    set_transient( $rate_limit_key, 1, MINUTE_IN_SECONDS );
} elseif ( (int) $request_count >= 10 ) {
    error_log( 'Email proxy: rate limit exceeded — IP: ' . $ip_address );
    wp_die( 'Too many requests', 'Error', [ 'response' => 429 ] );
} else {
    set_transient( $rate_limit_key, $request_count + 1, MINUTE_IN_SECONDS );
}

// ── All checks passed ─────────────────────────────────────────────────────

// Delete the token immediately — single-use only.
delete_transient( 'email_token_' . $token );

// Redirect to the real mailto: address.
$email = sanitize_email( $email_data['email'] );
header( 'Location: mailto:' . rawurlencode( $email ) );
exit;

The wp-load.php Path

The require_once path ('../../../wp-load.php') assumes your theme is located at:

wp-content/themes/your-theme/email-proxy.php

Three levels up (../../../) reaches the WordPress root where wp-load.php lives. If your WordPress installation is in a subdirectory, adjust the path accordingly. Alternatively, you can use a dynamic approach:

// More robust wp-load.php discovery
$wp_load = dirname( __FILE__ );
while ( ! file_exists( $wp_load . '/wp-load.php' ) ) {
    $wp_load = dirname( $wp_load );
    if ( $wp_load === '/' || $wp_load === '\\' ) {
        die( 'Cannot find wp-load.php' );
    }
}
require_once $wp_load . '/wp-load.php';

5. [Step 3] Using the Proxy in Templates

Replace every href="mailto:..." in your template files with a call to mytheme_get_email_proxy_url(). Add the class secure-email-link and the attribute data-proxy="true" to mark protected links consistently.

Basic Example

Before (unsafe):

<a href="mailto:<?php echo esc_attr( $contact_email ); ?>">Email Us</a>

After (protected):

<?php $email_proxy_url = mytheme_get_email_proxy_url( $contact_email ); ?>
<a href="<?php echo esc_url( $email_proxy_url ); ?>"
   class="secure-email-link"
   data-proxy="true">
    Email Us
</a>
Example With a Unique ID (for analytics or A/B testing)
<?php
$email_proxy_url = mytheme_get_email_proxy_url( $contact_email );
$email_link_id   = 'email-hero-' . substr( md5( $contact_email . microtime() ), 0, 8 );
?>
<a href="<?php echo esc_url( $email_proxy_url ); ?>"
   id="<?php echo esc_attr( $email_link_id ); ?>"
   class="secure-email-link"
   data-proxy="true"
   aria-label="Send us an email">
    Contact Us
</a>

The microtime() salt in md5() ensures the ID is unique even if the same email address appears twice on the same page load (e.g., header and footer).

When the Email Is Also the Visible Text
<?php
$email_proxy_url = mytheme_get_email_proxy_url( $contact_email );
?>
<a href="<?php echo esc_url( $email_proxy_url ); ?>"
   class="secure-email-link"
   data-proxy="true">
    <?php echo mytheme_obfuscate_email_text( $contact_email ); ?>
</a>

The obfuscation function is called only on the visible text. The href is already protected by the token.

6. [Step 4] Obfuscating Visible Email Text

As shown in the function above, mytheme_obfuscate_email_text() turns [email protected] into:

&#106;&#111;&#104;&#110;<!-- x -->&#64;&#101;&#120;&#97;<!-- -->&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;

A browser renders this identically to the original string. Also, screen readers handle HTML entities correctly. Although the difference is invisible to your visitors, it is nevertheless devastating to a harvesting bot looking for @ signs in text content.

You can disable the comment injection for cleaner markup (at a slight reduction in obfuscation) by passing false as the second argument:

echo mytheme_obfuscate_email_text( $contact_email, false );
// Output: &#106;&#111;&#104;&#110;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;

7. [Step 5] Handling Mailto Links Set by Admins

If your theme allows content editors to enter button URLs via custom fields or meta boxes, they may type mailto:[email protected] directly. You can intercept this automatically in any of your template. Check out this sample code:

$button_url = get_post_meta( get_the_ID(), 'cta_button_url', true );
$is_mailto  = strpos( $button_url, 'mailto:' ) === 0;

if ( $is_mailto ) {
    $raw_email   = str_replace( 'mailto:', '', $button_url );
    $button_url  = mytheme_get_email_proxy_url( $raw_email );
    $button_id   = 'email-cta-' . substr( md5( $raw_email . microtime() ), 0, 8 );
    ?>
    <a href="<?php echo esc_url( $button_url ); ?>"
       id="<?php echo esc_attr( $button_id ); ?>"
       class="secure-email-link"
       data-proxy="true">
        <?php echo esc_html( get_post_meta( get_the_ID(), 'cta_button_label', true ) ); ?>
    </a>
    <?php
} else {
    ?>
    <a href="<?php echo esc_url( $button_url ); ?>">
        <?php echo esc_html( get_post_meta( get_the_ID(), 'cta_button_label', true ) ); ?>
    </a>
    <?php
}

This way, the proxy is transparent to content editors. They enter mailto: links as usual; the template silently upgrades them to proxy URLs.

8. [OPTIONAL] Auto-Replace Mailto Links in Content 

Steps 3 through 5 cover mailto links you write directly in template files or that editors enter through meta boxes. Neither approach catches mailto: links that content editors have already placed inside post or page body content using the block editor or the classic editor. Those links live in the database as raw HTML and bypass the proxy entirely.

This bonus step hooks into the_content filter, intercepts the rendered HTML at display time, finds every <a href="mailto:..."> tag, and rewrites it as a proxy URL before the page is sent to the browser. Because this happens at render time and not in the database, tokens are always freshly generated and correctly single-use. There is nothing to migrate, no database update queries to run, and no risk of corrupting stored content.

Add this to functions.php

/**
 * Automatically replaces mailto: href values in post/page content with proxy URLs.
 * Runs at render time via the_content and render_block — the database is never modified.
 * If the anchor text is the bare email address, it is also obfuscated.
 *
 * @param string $content The post content (or block markup).
 * @return string Content with mailto: links replaced by secure proxy URLs.
 */
function mytheme_auto_replace_mailto_in_content( string $content ): string {
    // Early exit — avoids regex overhead on pages with no mailto links.
    if ( empty( $content ) || stripos( $content, 'mailto:' ) === false ) {
        return $content;
    }

    // Captures: attrs before href | quote char | mailto: + optional space + email (may contain &#64; etc.) | rest of href | attrs after | inner HTML
    // Permits optional whitespace after mailto: and subject/body query params.
    $pattern = '/<a\b([^>]*)\bhref\s*=\s*(["\'])mailto:\s*([^?"\'\s]+)(?:[^"\']*)\2([^>]*)>([\s\S]*?)<\/a>/i';

    $replaced = preg_replace_callback(
        $pattern,
        function ( array $matches ): string {
            $attrs_before = $matches[1];
            $email_raw    = $matches[3];
            $attrs_after  = $matches[4];
            $inner_html   = $matches[5];

            $email = sanitize_email( html_entity_decode( $email_raw, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );

            if ( empty( $email ) ) {
                return $matches[0];
            }

            $proxy_url = mytheme_get_email_proxy_url( $email );

            if ( strpos( $attrs_before . $attrs_after, 'secure-email-link' ) === false ) {
                if ( preg_match( '/\bclass=(["\'])([^"\']*)\1/', $attrs_before ) ) {
                    $attrs_before = preg_replace(
                        '/\bclass=(["\'])([^"\']*)\1/',
                        'class=$1$2 secure-email-link$1',
                        $attrs_before
                    );
                } elseif ( preg_match( '/\bclass=(["\'])([^"\']*)\1/', $attrs_after ) ) {
                    $attrs_after = preg_replace(
                        '/\bclass=(["\'])([^"\']*)\1/',
                        'class=$1$2 secure-email-link$1',
                        $attrs_after
                    );
                } else {
                    $attrs_after .= ' class="secure-email-link"';
                }
            }

            $inner_stripped = trim( wp_strip_all_tags( $inner_html ) );
            $inner_decoded  = html_entity_decode( $inner_stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
            if ( $inner_decoded === $email ) {
                $inner_html = mytheme_obfuscate_email_text( $email );
            }

            return sprintf(
                '<a%shref="%s"%s data-proxy="true">%s</a>',
                $attrs_before,
                esc_url( $proxy_url ),
                $attrs_after,
                $inner_html
            );
        },
        $content
    );

    return $replaced !== null ? $replaced : $content;
}

add_filter( 'the_content', 'mytheme_auto_replace_mailto_in_content', 20 );
add_filter( 'the_excerpt', 'mytheme_auto_replace_mailto_in_content', 20 );
add_filter( 'widget_text_content', 'mytheme_auto_replace_mailto_in_content', 20 );
add_filter( 'widget_text', 'mytheme_auto_replace_mailto_in_content', 20 );

// Process each block's output so mailto links inside blocks are replaced even when
// the full post content is assembled from block render output (e.g. block themes).
add_filter( 'render_block', 'mytheme_auto_replace_mailto_in_content', 20 );

The priority 20 ensures this filter runs after WordPress core and most plugins have already processed the content.

Extending to Other Content Areas

the_content only covers the main post body. If your site uses text widgets, custom excerpts, or other output hooks that could contain mailto links, apply the same function to those filters too:

HookCovers
the_contentPost and page body content
the_excerptManual excerpts
widget_text_contentBlock-based text widgets
widget_textClassic text widgets (pre-5.8)
add_filter( 'the_excerpt',          'mytheme_auto_replace_mailto_in_content', 20 );
add_filter( 'widget_text_content',  'mytheme_auto_replace_mailto_in_content', 20 );
add_filter( 'widget_text',          'mytheme_auto_replace_mailto_in_content', 20 );

⚠️ Limitations to Be Aware Of ⚠️

Mailto query parameters are dropped. A link like mailto:[email protected]?subject=Hello will be converted to a proxy URL that redirects to mailto:[email protected]. The ?subject= portion cannot be preserved, because the proxy endpoint constructs a clean mailto: redirect from the stored email address only. If pre-filled subjects are important, those links must be handled manually in templates using Step 5’s meta box pattern.

Multiple recipients are not supported. The mailto:[email protected],[email protected] syntax is not a common editor pattern, but if it appears in your content the regex will capture only the first address, and the second will be silently discarded. Flag those links for manual conversion.

Caching still applies. As noted in the FAQ, fully cached pages embed token URLs that expire after one hour. The filter does not bypass that constraint. Exclude pages with email links from full-page caching, or increase the transient TTL to match your cache lifetime.

8. Security Features Explained

Single-Use Tokens

Each page load generates a fresh token. Once a user clicks the link and the proxy processes the request, the transient is deleted with delete_transient(). Replaying the URL by hitting “back” and clicking again, or by copy-pasting the URL will return a 403 because the token no longer exists.

This prevents token harvesting: a bot cannot collect proxy URLs from the HTML and then batch-request them to extract email addresses.

Referrer Validation

if ( empty( $referrer ) || strpos( $referrer, home_url() ) !== 0 ) {
    wp_die( 'Invalid referrer', 'Error', [ 'response' => 403 ] );
}

Direct requests to email-proxy.php from external tools (curl, Postman, other websites) are rejected. The HTTP_REFERER header must start with your site’s own URL.

Limitation: Referrer headers can be spoofed. This check adds friction but is not the last line of defense. That role belongs to the token system.

Bot User-Agent Detection

$bot_patterns = [ 'bot', 'crawl', 'spider', 'scrape', 'harvest' ];

Well-behaved crawlers (Googlebot, Bingbot, etc.) identify themselves and are blocked here. The pattern list can be extended with terms like wgetpython-requests, or curl depending on your threat model.

Rate Limiting

The transient key email_proxy_rate_{md5($ip)} persists for one minute. Each request from the same IP increments the counter. At 10 requests, all further attempts return a 429. This limits both brute-force token guessing and aggressive scraping from a single IP.

Adjust the ceiling by changing 10 and the window by changing MINUTE_IN_SECONDS:

// Allow 5 requests per 30 seconds
if ( (int) $request_count >= 5 ) { ... }
set_transient( $rate_limit_key, $request_count + 1, 30 );

Token Expiry

Even if a bot somehow collects a token URL without clicking it immediately, the transient expires after HOUR_IN_SECONDS (one hour). After that, the token is gone from the database and the URL is permanently invalid.

9. Testing Your Implementation

Check That Emails Are Not in the Source

  1. Load any page that contains an email link.
  2. Right-click → View Page Source (Ctrl+U / Cmd+U).
  3. Search for @ (Ctrl+F).

You should find no email addresses in the source. The only URLs should be tokens, e.g.:

href="https://yoursite.com/wp-content/themes/your-theme/email-proxy.php?token=a8f3k2..."

Check That Clicking Opens the Mail Client

Click an email link. Your operating system’s default mail client should open with the To: field pre-filled with the correct address.

Security Checks

Expired/invalid toke testing:

curl -v "https://yoursite.com/wp-content/themes/your-theme/email-proxy.php?token=fakefakefake"
# Expected: HTTP 403

Missing referrer testing:

curl -v "https://yoursite.com/wp-content/themes/your-theme/email-proxy.php?token=<valid-token>"
# Expected: HTTP 403 (no Referer header sent by curl by default)

Bot user-agent testing:

curl -v -A "Googlebot/2.1" "https://yoursite.com/...?token=<valid-token>"
# Expected: HTTP 403

Rate limit testing:

for i in {1..12}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Referer: https://yoursite.com/" \
    "https://yoursite.com/...?token=<valid-token>"
done
# Expected: First 10 return 403 (bad token), 11th and 12th return 429 (rate limited)

Verify Single-Use Enforcement

  1. Open your browser’s developer tools and navigate to the Network tab.
  2. Click an email link and immediately copy the full proxy URL before the mail client opens.
  3. Paste the URL directly into a new browser tab.
  4. You should see a 403 “Invalid or expired token” error. This happens because the first click already consumed the token, making it invalid for any subsequent attempts.

9. Extra Protection: Block in robots.txt

Prevent search engines from crawling proxy URLs. Add this rule to your site’s robots.txt:

User-agent: *
Disallow: /email-proxy.php?*

10. FAQ

Frequently asked questions about email proxies.

Q: Will this break search engine indexing of my contact page?

No. Search engines follow links but the proxy URL returns a 403 for bots (the user-agent check blocks Googlebot by name). Googlebot will never reach the mailto: URI, so your email address is never exposed to the search crawler. Your contact page itself is indexed normally.

Q: What happens if a user’s browser blocks the HTTP_REFERER header?

Some privacy-focused browsers and extensions (Firefox with enhanced tracking protection, Brave, uBlock Origin) suppress or spoof the referrer. These users will receive a 403. This is the most significant usability trade-off of the approach.

To mitigate this, consider logging the blocked attempts and presenting a fallback:

if ( empty( $referrer ) || strpos( $referrer, home_url() ) !== 0 ) {
    // Instead of wp_die(), redirect to a contact page with a form
    wp_redirect( home_url( '/contact/' ) );
    exit;
}

Q: Does this work with caching plugins (WP Rocket, W3 Total Cache, etc.)?

The proxy file itself is direct PHP and bypasses the WordPress request loop, so page caches do not interfere with it. However, if your pages are fully cached, the token URLs embedded in the HTML will become stale after 1 hour (the transient TTL). To address this:

  1. Exclude pages with email links from full-page caching, or
  2. Increase the transient TTL to match your cache lifetime (e.g., DAY_IN_SECONDS), or
  3. Use fragment caching to keep only the email links dynamic.

Q: Should I add a nonce instead of relying on transients?

WordPress nonces are tied to a logged-in user session and are not appropriate for front-end visitors who are not logged in. The transient-based token system described here is the correct WordPress-native equivalent for anonymous user actions.

Q: Can I use this with AJAX-loaded content?

Yes. Since mytheme_get_email_proxy_url() is a PHP function, you can call it in any AJAX handler that returns HTML. The token is generated server-side at the time the HTML is rendered, AJAX or not, and the proxy flow is identical.

Q: Will this affect accessibility?

No. The <a> elements still carry an href attribute, aria-label attributes work as usual, and keyboard navigation is unaffected. Screen readers treat proxy URLs the same as any other link. Only after activation does the redirect occur to open the mail client.

Summary

ThreatMitigation
HTML source scrapingEmail never written to HTML, only a token URL
Bot crawlersUser-agent check blocks known crawlers
Token replaySingle-use tokens deleted after first use
Stale token abuse1-hour transient expiry
External tooling (curl)Referrer validation rejects requests without a same-site origin
Brute-force token guessingRate limit (10 req/min/IP) + 32-char token space
Visible email text scrapingHTML entity encoding + random comment injection

This approach requires no third-party plugin, no JavaScript, and adds negligible server overhead (two transient reads per click). It is compatible with any WordPress theme, works alongside all major caching plugins with minor configuration, and degrades gracefully. In short, even in the worst-case scenario, a user is merely redirected to your contact page with a form, rather than being shown a 403 error.

Github Repo