Tech Deep Dive

CSRF Nonce vs. Stateless Tokens: A WordPress Security Guide

· 11 min read

Introduction: The Caching Problem Nobody Talks About

You spend a week configuring Varnish, tuning your CDN rules, and enabling full-page caching across your WordPress site. Cache hit ratios climb to 95%. Page load times drop below 200ms. Everything looks perfect.

Then you check your forms. They are broken.

Every Contact Form 7 submission returns a validation error. Your WooCommerce checkout throws “session expired.” Your admin AJAX calls fail silently. The culprit is a WordPress CSRF nonce — a security token embedded in your HTML that expires every 12 hours and is unique to each logged-in user.

This is not an edge case. It is the single most common conflict between WordPress security and modern caching infrastructure. If you have ever Googled “WordPress nonce caching issue” at 2 AM, you know exactly what this feels like.

This article breaks down the mechanics of WordPress CSRF nonces, explains precisely why they conflict with caching layers, and presents an alternative: stateless HMAC tokens that provide equivalent CSRF protection without requiring server-side state or user-specific HTML.


The Problem: Why WordPress Nonces Break Your Cache

What Is a CSRF Nonce?

Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a user’s browser into submitting a request to your site, piggy-backing on the user’s authenticated session. The classic defense is a nonce — a single-use token that proves the request originated from your own page.

WordPress implements this through wp_create_nonce() and wp_verify_nonce(). Despite the name, WordPress “nonces” are not truly single-use. They are time-limited HMAC signatures tied to four inputs:

  1. The nonce action (a string you define, like 'cf7_submit_form_42')
  2. The current user ID (or 0 for logged-out users)
  3. The current session token (from the auth cookie)
  4. A time-based “tick” (changes every 12 hours)

Here is the simplified generation logic inside WordPress core:

// Simplified from wp-includes/pluggable.php
function wp_create_nonce( $action = -1 ) {
    $user = wp_get_current_user();
    $uid  = (int) $user->ID;
    $token = wp_get_session_token();
    $tick  = wp_nonce_tick(); // Changes every 12 hours

    return substr(
        wp_hash( $tick . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ),
        -12,
        10
    );
}

Verification checks the current tick and the previous tick, giving each nonce a 12-to-24-hour validity window.

The Cache-Killing Mechanism

Here is the problem. Look at the inputs again: user ID and session token.

When WordPress renders a page with a form, the nonce is baked into the HTML:

<input type="hidden" name="_wpnonce" value="a3f7b29d01" />

That value is different for every logged-in user. It is also different for logged-out users across 12-hour windows. This means:

  • Full-page caching serves the same HTML to all visitors. But the nonce in that cached HTML belongs to the user who triggered the cache. Every other user gets a stale or wrong nonce.
  • CDN edge caching amplifies the problem. A page cached at a Cloudflare edge node in Tokyo contains a nonce generated for a user in London. Every visitor hitting that edge node gets a token that will fail validation.
  • Reverse proxies (Varnish, Nginx FastCGI cache) exhibit the same behavior. The first request warms the cache; every subsequent request gets that first user’s nonce.

The result: form submissions fail with cryptic validation errors. Users see “session expired” or “security check failed” messages on forms they just loaded 30 seconds ago.

Common Workarounds (And Why They Are Insufficient)

Most developers reach for one of these fixes:

1. Exclude form pages from caching.

# Nginx example: bypass cache for pages with forms
if ($request_uri ~* "/contact|/checkout") {
    set $skip_cache 1;
}

This works but defeats the purpose. If your most important conversion pages are uncached, you lose the performance gains where they matter most.

2. Load nonces via AJAX after page load.

// Fetch a fresh nonce after the cached page loads
fetch('/wp-admin/admin-ajax.php?action=get_fresh_nonce')
  .then(res => res.json())
  .then(data => {
    document.querySelector('[name="_wpnonce"]').value = data.nonce;
  });

This is the most popular fix. It keeps the page cacheable and fetches a valid nonce client-side. But it introduces its own problems:

  • An extra HTTP round-trip before the form is submittable
  • The AJAX endpoint itself cannot be cached (it returns user-specific data)
  • Race conditions if the user submits before the nonce loads
  • JavaScript dependency — the form is insecure if JS fails to execute

3. Use Cache-Control: no-store headers selectively.

This is just option 1 with extra steps.

None of these solutions address the root cause: the token depends on server-side state that changes per user and per time window.


Technical Deep Dive: Stateless HMAC Tokens

The Core Idea

What if the token did not depend on the user’s session at all? What if it carried all the information needed for its own verification, without requiring the server to remember anything?

This is the principle behind stateless HMAC tokens. Instead of binding the token to a user session, you bind it to the request itself — using properties that a legitimate browser will naturally possess and an attacker’s forged request will not.

A stateless CSRF token typically encodes:

  • A timestamp (for expiration)
  • A form identifier (to prevent cross-form replay)
  • An HMAC signature over those fields, using a server-side secret key

The server does not need to store anything. It does not need to look up the current user. It just verifies the HMAC and checks the timestamp.

Implementation: WordPress Stateless Token

Here is a working implementation:

/**
 * Generate a stateless CSRF token.
 * No user ID. No session. No database. Fully cacheable.
 */
function stateless_csrf_token( string $form_id, int $ttl = 3600 ): string {
    $secret    = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' );
    $timestamp = time();
    $payload   = $timestamp . '|' . $form_id;
    $signature = hash_hmac( 'sha256', $payload, $secret );

    // Encode as: timestamp.signature
    return $timestamp . '.' . $signature;
}

/**
 * Verify a stateless CSRF token.
 */
function verify_stateless_csrf( string $token, string $form_id, int $ttl = 3600 ): bool {
    $parts = explode( '.', $token, 2 );
    if ( count( $parts ) !== 2 ) {
        return false;
    }

    [ $timestamp, $provided_signature ] = $parts;

    // Check expiration
    if ( ( time() - (int) $timestamp ) > $ttl ) {
        return false;
    }

    // Recompute the expected signature
    $secret           = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' );
    $payload          = $timestamp . '|' . $form_id;
    $expected_signature = hash_hmac( 'sha256', $payload, $secret );

    // Timing-safe comparison
    return hash_equals( $expected_signature, $provided_signature );
}

And the front-end usage:

// In your form template (can be cached forever)
$token = stateless_csrf_token( 'contact_form_main' );
echo '<input type="hidden" name="_csrf_token" value="' . esc_attr( $token ) . '" />';
// In your form handler
$token   = sanitize_text_field( $_POST['_csrf_token'] ?? '' );
$is_valid = verify_stateless_csrf( $token, 'contact_form_main' );

if ( ! $is_valid ) {
    wp_die( 'Invalid or expired security token.', 403 );
}

Why This Is Cache-Safe

The token generated by stateless_csrf_token() does not contain a user ID or session token. Two different users loading the same cached page at roughly the same time get a token that will verify correctly for both of them, because verification only checks:

  1. Is the HMAC signature valid? (Yes, because the server secret has not changed.)
  2. Is the timestamp within the TTL window? (Yes, unless the cached page is older than your TTL.)

You can cache the entire page — HTML, form, token included — at your CDN edge. As long as the cache TTL is shorter than the token TTL, every visitor gets a working form.

Strengthening the Stateless Approach

The basic implementation above prevents CSRF. To harden it further, you can layer on additional signals without breaking cacheability:

Add a client-generated fingerprint:

// Client-side: generate a fingerprint and include it with submission
const fp = btoa(navigator.userAgent + screen.width + screen.height);
document.querySelector('[name="_csrf_fp"]').value = fp;
// Server-side: include the fingerprint in a secondary validation layer
// This does NOT affect cacheability since the fingerprint is submitted, not embedded
$fp = sanitize_text_field( $_POST['_csrf_fp'] ?? '' );
if ( empty( $fp ) ) {
    // No fingerprint = likely a raw HTTP request from a bot
    wp_die( 'Security check failed.', 403 );
}

Use the Origin and Referer headers as a defense-in-depth check:

function verify_request_origin(): bool {
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    $referer = wp_parse_url( $_SERVER['HTTP_REFERER'] ?? '', PHP_URL_HOST );
    $site_host = wp_parse_url( home_url(), PHP_URL_HOST );

    if ( ! empty( $origin ) ) {
        return wp_parse_url( $origin, PHP_URL_HOST ) === $site_host;
    }

    return $referer === $site_host;
}

Neither of these additions requires per-user state.


Side-by-Side Comparison

Aspect WordPress Nonce (wp_create_nonce) Stateless HMAC Token
Server-side state Requires user session + DB lookup None. Secret key only.
User-specific Yes (bound to user ID + session token) No (bound to form ID + timestamp)
Full-page cacheable No. Token varies per user. Yes. Token is identical for all users.
CDN/Edge cacheable No, unless loaded via AJAX workaround. Yes, natively.
Token lifetime 12-24 hours (tick-based) Configurable TTL (e.g., 1 hour)
Replay protection Partial (same token valid for 12-24h) Partial (same token valid for TTL window)
Single-use enforcement No (despite the name “nonce”) No (requires server-side storage to enforce)
Works for logged-in users Yes, with user-level binding Yes, but without user-level binding
Works for logged-out users Yes, but weakly (user ID = 0 for all) Yes, equally strong for all visitors
GDPR impact Ties to session token (arguable PII link) No PII involved
Implementation complexity Built into WordPress core ~30 lines of custom code
Best suited for Admin actions, authenticated endpoints Public forms, cached pages, API endpoints

The Solution: Choosing the Right Tool

When to Use WordPress Nonces

WordPress nonces remain the correct choice for authenticated administrative actions:

  • Deleting a post from the admin dashboard
  • Updating plugin settings
  • Any action where you need to confirm that this specific logged-in user initiated the request

The user-binding is a feature here, not a bug. You want to know that the delete request came from an admin, not just from someone who loaded the page.

// Admin context: nonces are appropriate
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'delete_post_' . $post_id ) ) {
    wp_die( 'You do not have permission to delete this post.' );
}

When to Use Stateless Tokens

Stateless HMAC tokens are the better choice for public-facing, cache-friendly scenarios:

  • Contact forms on cached landing pages
  • Newsletter signup forms
  • Comment forms on high-traffic posts
  • Any form served to logged-out visitors through a CDN

In these cases, there is no meaningful “user” to bind to. The WordPress nonce for a logged-out visitor uses user_id = 0 and an empty session token — so the “user binding” provides zero additional security anyway. You get all of the caching headaches with none of the security benefit.

The Architecture Decision

Think of it as a simple rule:

  • Behind wp-admin? Use wp_nonce.
  • In front of a CDN? Use a stateless token.

For most WordPress sites, the admin uses nonces and the public site uses stateless tokens. They coexist without conflict.

Practical Integration

If you are building a Contact Form 7 extension or custom form handler, the stateless approach integrates cleanly:

add_filter( 'wpcf7_form_hidden_fields', function( $fields ) {
    $fields['_stateless_csrf'] = stateless_csrf_token( 'cf7_global' );
    return $fields;
});

add_filter( 'wpcf7_validate', function( $result, $tags ) {
    $token = sanitize_text_field( $_POST['_stateless_csrf'] ?? '' );
    if ( ! verify_stateless_csrf( $token, 'cf7_global' ) ) {
        $result->invalidate(
            $tags[0],
            'Security verification failed. Please reload the page.'
        );
    }
    return $result;
}, 10, 2 );

This gives you CSRF protection on fully cached pages with zero JavaScript dependency and zero extra HTTP requests.


A Note on Samurai Honeypot for Forms

If you use Contact Form 7 and want this kind of stateless, cache-compatible security without rolling your own implementation, Samurai Honeypot for Forms takes this approach out of the box. It uses stateless HMAC verification combined with polymorphic honeypot fields and client-side proof-of-work challenges — all designed to work behind aggressive caching layers without AJAX nonce workarounds. Worth examining if you are tired of fighting the nonce-vs-cache battle on every project.


Key Takeaways

  1. WordPress nonces are not true nonces. They are time-limited HMAC tokens bound to user sessions. They were designed for admin actions, not public forms.

  2. Nonces break full-page caching because they embed user-specific data in the HTML response. Every workaround (AJAX loading, cache exclusion) introduces performance or reliability trade-offs.

  3. Stateless HMAC tokens provide equivalent CSRF protection for public forms without requiring user sessions or server-side state. They are inherently cache-safe.

  4. Use both. Nonces for authenticated admin actions. Stateless tokens for public-facing forms. They solve different problems.

  5. Always use hash_equals() for timing-safe comparison of HMAC signatures. Never use === or strcmp() for cryptographic comparisons.


Further reading:

All Columns