WordPress Nonce Internals: How They Actually Work and Where They Fail
What a WordPress Nonce Actually Is
The word “nonce” stands for “number used once.” In classical cryptography, a nonce is a random value that must never repeat. WordPress nonces do not meet that definition. They are not random, they are not single-use, and they are not numbers. A WordPress nonce is a 10-character hexadecimal string derived from a keyed HMAC hash that remains valid for a sliding window of 12 to 24 hours.
Understanding this gap between the cryptographic definition and the WordPress implementation matters. Developers who assume nonces behave like true cryptographic nonces will build systems with security assumptions that WordPress cannot guarantee. This article walks through the actual source code of the nonce system, explains its mechanics in detail, identifies the weak points, and offers practical guidance for plugin developers.
Every code reference in this article points to real functions in wp-includes/pluggable.php, wp-includes/functions.php, and wp-includes/user.php as they exist in WordPress core. Nothing is invented or simplified for illustration.
The Tick System: wp_nonce_tick()
The nonce system depends on a concept WordPress calls “ticks.” A tick is a time-based counter that increments every half-lifetime of a nonce. The function responsible for computing the current tick is wp_nonce_tick(), located in wp-includes/pluggable.php.
Here is what the function does:
function wp_nonce_tick( $action = -1 ) {
$nonce_life = apply_filters( 'nonce_life', DAY_IN_SECONDS, $action );
return ceil( time() / ( $nonce_life / 2 ) );
}
The default nonce lifetime is DAY_IN_SECONDS, which equals 86400 seconds (24 hours). The function divides the current Unix timestamp by half that value (43200 seconds, or 12 hours), then rounds up with ceil().
This produces an integer that changes every 12 hours. When the tick value changes, any nonce generated during the previous tick enters its grace period, and any nonce from two ticks ago becomes invalid.
The tick value is not tied to any specific event or request. It is purely a function of wall clock time divided by a constant. Two different servers with synchronized clocks will produce the same tick value. Two requests that happen one second apart might produce different tick values if they straddle a tick boundary.
Why Half-Lifetime?
The division by 2 is the reason nonces have a validity window of 12 to 24 hours instead of exactly 24 hours. When you create a nonce, it gets stamped with the current tick. When you verify that nonce later, verification checks both the current tick and the previous tick. If your nonce was created at the very start of a tick period, it stays valid for nearly the full two tick periods (close to 24 hours). If it was created right before a tick boundary, it stays valid for just over one tick period (slightly more than 12 hours).
This is a deliberate design trade-off. WordPress chose simplicity and predictability over precision. There is no per-nonce timestamp stored anywhere. The tick system avoids the need for server-side state entirely.
Tick Boundaries and Edge Cases
A critical consequence of the tick system is that nonce validity is not measured from the moment of creation. It is determined by which tick period the creation and verification fall into. Suppose a nonce is created at 11:59:59 of a tick period, and verified at 12:00:01 of the next tick period. Only two seconds have passed, but the nonce has already consumed one of its two valid ticks.
If you generate a nonce and then a tick boundary passes, verification returns 2 instead of 1. The return value tells the caller which tick matched. A return of 1 means the nonce matched the current tick (generated 0-12 hours ago by default). A return of 2 means the nonce matched the previous tick (generated 12-24 hours ago by default). Many developers ignore this return value, but it exists for a reason: plugins can use it to trigger a nonce refresh when the value is 2.
Nonce Creation: wp_create_nonce()
The function wp_create_nonce() produces the actual nonce string. Here is the complete implementation:
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 function gathers four pieces of data:
1. The tick value ($i): The current time-based tick from wp_nonce_tick().
2. The action ($action): A string that identifies what operation the nonce protects. For example, 'update-post_123' or 'delete-comment_456'.
3. The user ID ($uid): The ID of the currently logged-in user, or 0 for guests.
4. The session token ($token): A per-session value extracted from the logged-in cookie by wp_get_session_token().
These four values are concatenated with pipe separators into a single string like 52648412|update-post_123|7|a1b2c3d4e5f6. That string is then passed through wp_hash() with the 'nonce' scheme.
The final nonce is 10 characters extracted from the hash output using substr( $hash, -12, 10 ). This takes 10 characters starting from 12 characters before the end of the hash string.
The wp_hash() Function
wp_hash() is a thin wrapper around PHP’s hash_hmac():
function wp_hash( $data, $scheme = 'auth', $algo = 'md5' ) {
$salt = wp_salt( $scheme );
return hash_hmac( $algo, $data, $salt );
}
When called with the 'nonce' scheme, wp_salt( 'nonce' ) concatenates the NONCE_KEY and NONCE_SALT constants defined in wp-config.php. The default hashing algorithm is MD5.
The use of MD5 here is not a vulnerability in the way that using MD5 for password hashing would be. HMAC-MD5 is a message authentication code, not a bare hash. The security of HMAC does not depend on collision resistance in the same way that signature verification does. HMAC-MD5 remains considered safe for authentication purposes by most cryptographers, though the WordPress community has discussed upgrading to SHA-256 or SHA-512 for defense in depth.
The output of hash_hmac( 'md5', $data, $salt ) is a 32-character hexadecimal string. Taking 10 characters from it gives 40 bits of entropy, which provides roughly one trillion possible values. That is sufficient to prevent brute-force attacks within the 12-24 hour validity window.
Nonce Verification: wp_verify_nonce()
Verification reverses the creation process. Instead of looking up a stored nonce, the function recomputes what the nonce should be and compares:
function wp_verify_nonce( $nonce, $action = -1 ) {
$nonce = (string) $nonce;
$user = wp_get_current_user();
$uid = (int) $user->ID;
if ( ! $uid ) {
$uid = apply_filters( 'nonce_user_logged_out', $uid, $action );
}
if ( empty( $nonce ) ) {
return false;
}
$token = wp_get_session_token();
$i = wp_nonce_tick( $action );
// Nonce generated 0-12 hours ago.
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
return 1;
}
// Nonce generated 12-24 hours ago.
$expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
if ( hash_equals( $expected, $nonce ) ) {
return 2;
}
return false;
}
The function computes the expected nonce for the current tick and compares it to the submitted nonce using hash_equals(), which is a timing-safe comparison function. If that fails, it tries the previous tick. If both fail, the nonce is invalid.
Notice that the same four inputs are needed: tick, action, user ID, and session token. If any one of these differs between creation and verification, the nonce will not match. This means:
A nonce created for user 7 cannot be verified by user 8.
A nonce created for action 'delete-post_5' cannot verify action 'delete-post_6'.
A nonce created in one browser session cannot be verified in a different session (because the session token differs).
The use of hash_equals() instead of === or == is important. String comparison operators in PHP can leak timing information: the comparison short-circuits as soon as it finds a differing byte, so a nonce that matches more leading characters takes microscopically longer to reject. An attacker with extremely precise timing measurements could theoretically use this to reconstruct the nonce byte by byte. hash_equals() always takes constant time regardless of where the strings differ.
The Return Value Semantics
Most developers treat wp_verify_nonce() as a boolean, but it returns three distinct values:
1 (integer): The nonce is valid and was generated during the current tick (0-12 hours ago with default settings).
2 (integer): The nonce is valid but was generated during the previous tick (12-24 hours ago with default settings).
false (boolean): The nonce is invalid.
The distinction between 1 and 2 enables aging-aware logic. WordPress core uses this in wp_verify_nonce() itself when the Heartbeat API refreshes nonces on long-lived admin pages. When a nonce is getting old (return value 2), the system can proactively issue a new one.
A common bug in plugin code is checking the return value with strict equality against true:
// WRONG: This will always fail.
if ( wp_verify_nonce( $_POST['_wpnonce'], 'my_action' ) === true ) {
// never reached
}
// CORRECT: Check for a truthy value.
if ( wp_verify_nonce( $_POST['_wpnonce'], 'my_action' ) ) {
// works
}
Since wp_verify_nonce() returns 1 or 2, neither of which is strictly equal to true, the strict comparison always fails. This is a real bug that has appeared in production plugins.
The Guest User Problem: User ID 0
When a user is not logged in, wp_get_current_user() returns a user object with ID 0. The session token from wp_get_session_token() will be an empty string because there is no logged-in cookie.
This means every unauthenticated visitor produces a nonce from the same inputs: tick, action, user ID 0, and an empty session token. Every single guest on the site will generate and accept the exact same nonce for a given action during a given tick period.
If your site has a public-facing form protected by a nonce (a comment form, a front-end submission form, a newsletter signup), the nonce for that form is effectively a shared secret among all anonymous users. An attacker can visit the page, extract the nonce, and use it from a completely different session, IP address, or browser. The nonce provides zero CSRF protection for unauthenticated users.
The nonce_user_logged_out filter exists partly to address this:
if ( ! $uid ) {
$uid = apply_filters( 'nonce_user_logged_out', $uid, $action );
}
A plugin could hook this filter to return a value derived from the visitor’s IP address, a cookie, or some other identifier. But WordPress core does not do this by default, and most plugins do not either.
Practical Impact
For forms that require authentication (post editing, settings changes, plugin activation), the guest nonce weakness is irrelevant because authentication gates the action anyway. But for public forms (comments, front-end submissions, AJAX endpoints accessible to nopriv users), this means nonces alone are insufficient for CSRF protection against anonymous users. You need additional measures: honeypot fields, rate limiting, referrer checking, or CAPTCHA.
The WordPress comment form is a well-known example. Comments do not use a nonce at all by default, partially because nonces would provide false confidence for anonymous commenters while adding friction for legitimate users.
Customizing Nonce Lifetime with the nonce_life Filter
The nonce_life filter lets you change the duration for which nonces remain valid:
add_filter( 'nonce_life', function( $lifespan, $action ) {
if ( $action === 'my_sensitive_action' ) {
return 4 * HOUR_IN_SECONDS; // 4 hours instead of 24
}
return $lifespan;
}, 10, 2 );
Since WordPress 6.1, the filter receives the $action parameter, allowing per-action lifetime customization. Before 6.1, you could only set a global lifetime for all nonces.
Reducing the lifetime narrows the attack window. A 4-hour lifetime means the tick period becomes 2 hours (half the lifetime), so nonces are valid for 2 to 4 hours. For highly sensitive operations like financial transactions or user deletion, shorter lifetimes reduce risk.
Dangers of Extending Nonce Lifetime
Extending the nonce lifetime beyond 24 hours is technically possible but introduces real problems. Longer lifetimes mean a leaked nonce remains exploitable for longer. They also interact poorly with the session token component: if a user’s session expires before their nonces do, you get confusing failures where a page appears to have a valid nonce but verification fails because the session token no longer matches.
Some caching plugins extend nonce lifetimes to work around page cache invalidation. This is a hack with security trade-offs. A cached page serves a stale nonce to all visitors. If the nonce lifetime is short, those cached nonces expire before the page cache does, causing form submission failures. Extending the nonce lifetime “fixes” this by keeping stale nonces valid longer, but at the cost of widening the attack window. The better approach is to exclude pages with forms from caching, or to load nonces dynamically via AJAX after the cached page loads.
Interaction with Tick Boundaries
When you change the nonce lifetime, you change the tick period. The tick period is always half the lifetime. With a 4-hour lifetime, the tick period is 2 hours. The tick value itself is ceil( time() / 7200 ) where 7200 is 4 hours divided by 2. Tick boundaries now fall at different absolute times than the default configuration.
This has a subtle implication: if you change the nonce lifetime dynamically (based on action, user role, or any other variable), the tick boundaries shift. A nonce generated with one lifetime and verified with a different lifetime will almost certainly fail because the tick values will not align. You must ensure that creation and verification use the same lifetime.
AJAX Nonce Refresh with the Heartbeat API
Admin pages in WordPress can remain open for hours. A user might open the post editor in the morning, leave for lunch, and come back to save their draft in the afternoon. By then, the nonce embedded in the page might have expired.
WordPress solves this with the Heartbeat API, which sends periodic AJAX requests to the server (by default, every 15-120 seconds depending on activity). Each Heartbeat response can include a fresh nonce.
The relevant JavaScript pattern looks like this:
// In your JavaScript, listen for the Heartbeat tick response:
jQuery( document ).on( 'heartbeat-tick', function( event, data ) {
if ( data.wp_auth_check === false ) {
// Session expired, show re-login dialog
return;
}
if ( data.my_plugin_nonce ) {
// Update the nonce in the form
jQuery( '#my-form input[name="_wpnonce"]' ).val( data.my_plugin_nonce );
}
});
On the server side:
add_filter( 'heartbeat_received', function( $response, $data ) {
if ( isset( $data['my_plugin_nonce_check'] ) ) {
$response['my_plugin_nonce'] = wp_create_nonce( 'my_plugin_action' );
}
return $response;
}, 10, 2 );
WordPress core uses this pattern for the post editor. The wp-auth-check Heartbeat response includes a flag indicating whether the user’s session is still valid. If the session has expired, an overlay appears prompting the user to log in again.
Heartbeat Frequency and Server Load
The Heartbeat API is a polling mechanism. Every tick means an HTTP request to admin-ajax.php. On shared hosting or sites with many concurrent admin users, this creates significant server load. WordPress allows adjusting the interval:
add_filter( 'heartbeat_settings', function( $settings ) {
$settings['interval'] = 60; // seconds
return $settings;
});
The minimum interval is 15 seconds, and the maximum is 120 seconds. Some performance plugins disable Heartbeat entirely on certain pages. If you do this, you lose automatic nonce refresh, and long-lived pages will eventually fail nonce verification.
A middle ground is to set a longer interval (60-120 seconds) on pages where nonce freshness is not critical, and keep the default on pages with active forms. You can also use the heartbeat_settings filter to adjust this per-screen.
Custom AJAX Nonce Refresh Without Heartbeat
If you cannot use the Heartbeat API (because it is disabled, or you are building a front-end form outside the admin), you can implement your own refresh mechanism:
// PHP: Register an AJAX endpoint for nonce refresh
add_action( 'wp_ajax_my_plugin_refresh_nonce', function() {
wp_send_json_success( array(
'nonce' => wp_create_nonce( 'my_plugin_action' ),
));
});
// JavaScript: Periodically refresh the nonce
setInterval( function() {
jQuery.post( ajaxurl, {
action: 'my_plugin_refresh_nonce',
}, function( response ) {
if ( response.success ) {
myCurrentNonce = response.data.nonce;
}
});
}, 600000 ); // Every 10 minutes
This approach gives you control over the refresh interval and does not depend on the Heartbeat infrastructure. The trade-off is that you are responsible for error handling, session expiry detection, and cleanup.
Nonces in the REST API Context
The WordPress REST API introduces a different authentication context that changes how nonces work. When you make REST API requests from JavaScript in the admin, the typical pattern uses cookie-based authentication with a nonce:
// WordPress localizes this as wpApiSettings.nonce
wp.apiFetch( {
path: '/wp/v2/posts/123',
method: 'POST',
data: { title: 'Updated Title' },
} );
The wp.apiFetch middleware automatically attaches the nonce as the X-WP-Nonce header. This nonce is created with the action 'wp_rest' and is output in the page by wp_localize_script().
On the server side, rest_cookie_check_errors() validates the nonce when the request includes WordPress authentication cookies. If the cookies are present but the nonce is missing or invalid, the REST API downgrades the request to unauthenticated, even if the cookie itself is valid. This prevents CSRF attacks: a malicious site cannot make authenticated REST API requests using the victim’s cookies because it cannot obtain the nonce.
REST API vs. Application Passwords and OAuth
When authentication uses Application Passwords, OAuth tokens, or other token-based methods, no cookies are involved. In these cases, the nonce is not required and not checked. The authentication token itself provides CSRF protection because the attacker would need to know the token to forge a request.
The nonce is specifically tied to cookie-based authentication. This is a point of confusion for developers building headless WordPress applications. If your front-end authenticates via Application Passwords, you do not need to send X-WP-Nonce. If your front-end relies on WordPress cookies (same-origin requests in the admin), you do.
REST API Nonce Expiry
The REST API nonce uses the 'wp_rest' action and the default 24-hour lifetime. For single-page applications that keep the admin open for extended periods, the nonce will eventually expire. The wp.apiFetch middleware in WordPress 5.0+ includes automatic nonce refresh: when a REST API request returns a new nonce in the X-WP-Nonce response header, the middleware updates its stored nonce for subsequent requests.
This is implemented in wp-includes/js/api-fetch.js. The middleware checks for the response header and updates its internal state:
// Simplified version of the actual middleware logic
const nonceMiddleware = ( options, next ) => {
const headers = options.headers || {};
headers['X-WP-Nonce'] = nonce;
return next( { ...options, headers } ).then( result => {
if ( result.headers ) {
const newNonce = result.headers.get( 'X-WP-Nonce' );
if ( newNonce ) {
nonce = newNonce;
}
}
return result;
});
};
This automatic refresh means REST API consumers generally do not need to worry about nonce expiry during active use. Problems arise only when the page is idle for long periods without any requests.
Common Vulnerabilities and Mistakes
1. Nonce Reuse Across Actions
The most common vulnerability is using a single nonce for multiple distinct actions:
// DANGEROUS: One nonce protects two different actions
$nonce = wp_create_nonce( 'my_plugin' );
// Used for both "update_settings" and "delete_all_data"
If an attacker obtains the nonce (which may be exposed in a page source or URL), they can use it for any action that shares the same nonce action string. Always use specific action strings that include the operation and the target:
// CORRECT: Specific action strings
$update_nonce = wp_create_nonce( 'my_plugin_update_settings' );
$delete_nonce = wp_create_nonce( 'my_plugin_delete_item_42' );
Including the object ID in the action string is especially important for CRUD operations. A nonce for 'delete_item_42' cannot be used to delete item 43.
2. Nonce Leakage in URLs
wp_nonce_url() appends the nonce as a query parameter:
$url = wp_nonce_url( admin_url( 'admin.php?action=delete&id=42' ), 'delete_item_42' );
// Produces: /wp-admin/admin.php?action=delete&id=42&_wpnonce=abc1234def
This URL will appear in browser history, server access logs, the Referer header sent to external sites, and potentially in analytics tools. Any of these can leak the nonce.
For state-changing operations, prefer POST requests with wp_nonce_field() over GET requests with wp_nonce_url(). When you must use GET (for example, for admin action links in list tables), be aware of the exposure and keep nonce lifetimes short.
WordPress core uses wp_nonce_url() extensively in the admin for things like plugin activation, theme switching, and post trashing. These URLs are generated dynamically and include the current user’s nonce, so they are different for each user. The risk is mitigated by the fact that admin pages are behind authentication and the nonces are user-specific. But the pattern should not be replicated on public-facing pages.
3. Missing Nonce Verification
Some plugins create nonces dutifully but forget to verify them:
// In the form template:
wp_nonce_field( 'save_settings', '_my_nonce' );
// In the handler:
function handle_form_submission() {
// Oops, no nonce verification!
update_option( 'my_setting', $_POST['value'] );
}
Without wp_verify_nonce() or check_admin_referer(), the nonce is decorative. It is generated and submitted but never checked. CSRF protection requires verification at the point of action.
The check_admin_referer() function is a convenience wrapper that calls wp_verify_nonce() and terminates the request if verification fails:
function handle_form_submission() {
check_admin_referer( 'save_settings', '_my_nonce' );
// This line only runs if the nonce is valid
update_option( 'my_setting', sanitize_text_field( $_POST['value'] ) );
}
For AJAX handlers, use check_ajax_referer() instead, which looks for the nonce in $_REQUEST['_ajax_nonce'] or $_REQUEST['_wpnonce']:
add_action( 'wp_ajax_my_action', function() {
check_ajax_referer( 'my_action_nonce', '_ajax_nonce' );
// Process the request
wp_send_json_success( $result );
});
4. Timing Attacks on Nonce Comparison
If you manually compare nonces instead of using wp_verify_nonce(), you might introduce a timing vulnerability:
// VULNERABLE to timing attack
if ( $_POST['_wpnonce'] === $expected_nonce ) {
// process
}
// SAFE: constant-time comparison
if ( hash_equals( $expected_nonce, $_POST['_wpnonce'] ) ) {
// process
}
This is why you should always use wp_verify_nonce() rather than computing and comparing nonces yourself. The core function already uses hash_equals().
In practice, timing attacks against nonces over a network are extremely difficult to pull off. Network jitter typically dwarfs the timing differences from string comparison. But on localhost or very fast local networks, the attack becomes more feasible. Using constant-time comparison is cheap insurance.
5. Nonces and Caching
Full-page caching is the single biggest source of nonce-related problems on WordPress sites. When a page containing a nonce is cached, every visitor receives the same nonce. For logged-in users, this means they receive a nonce generated for a different user, which will fail verification. For logged-out users, the shared nonce might still “work” (because all guests get the same nonce), but the cached nonce will eventually expire while the page cache remains active.
Solutions include:
Fragment caching: Cache the page but exclude nonce-containing fragments. This requires the caching layer to support ESI (Edge Side Includes) or similar mechanisms.
JavaScript nonce loading: Serve the page from cache without nonces, then fetch fresh nonces via AJAX after page load. This is the most reliable approach but adds a request.
Cache segmentation: Serve different cached versions to logged-in vs. logged-out users. Most caching plugins do this by default, but you need to verify it is working correctly.
Short cache TTL: Set the page cache TTL shorter than the nonce lifetime. This ensures cached nonces are still valid when served.
6. Nonce in the Referer Header
When a page URL contains a nonce (via wp_nonce_url()) and that page links to an external site, the browser sends the full URL including the nonce in the Referer header. The external site’s server logs now contain your nonce.
Modern browsers limit the Referer header by default (sending only the origin, not the full URL), but this depends on the Referrer-Policy header and the browser’s configuration. To mitigate this, set a restrictive Referrer-Policy:
header( 'Referrer-Policy: strict-origin-when-cross-origin' );
Or use the meta tag equivalent on pages that contain nonces in URLs.
Proper Nonce Implementation Patterns for Plugins
Pattern 1: Admin Settings Form
This is the most common pattern. A settings page in the WordPress admin with a form that saves options:
// Rendering the form:
function my_plugin_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
<form method="post" action="options.php">
<?php
settings_fields( 'my_plugin_options' );
do_settings_sections( 'my-plugin' );
submit_button();
?>
</form>
</div>
<?php
}
When you use the Settings API (settings_fields(), register_setting()), WordPress handles nonce creation and verification automatically. The settings_fields() function outputs a nonce field with the action 'my_plugin_options-options' and a referrer field.
If you are not using the Settings API, do it manually:
// Rendering:
<form method="post" action="">
<?php wp_nonce_field( 'my_plugin_save_settings', '_my_plugin_nonce' ); ?>
<!-- form fields -->
<input type="submit" value="Save" />
</form>
// Handling:
function my_plugin_handle_save() {
if ( ! isset( $_POST['_my_plugin_nonce'] ) ) {
return;
}
if ( ! wp_verify_nonce( $_POST['_my_plugin_nonce'], 'my_plugin_save_settings' ) ) {
wp_die( 'Security check failed.' );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized.' );
}
// Safe to process
update_option( 'my_plugin_option', sanitize_text_field( $_POST['my_option'] ) );
wp_redirect( add_query_arg( 'updated', 'true' ) );
exit;
}
Note the three checks: nonce presence, nonce validity, and capability. All three are required. A valid nonce from a subscriber user should not allow saving admin settings.
Pattern 2: AJAX Form Submission
// PHP: Enqueue script and localize nonce
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_script(
'my-plugin-ajax',
plugin_dir_url( __FILE__ ) . 'js/ajax.js',
array( 'jquery' ),
'1.0.0',
true
);
wp_localize_script( 'my-plugin-ajax', 'MyPlugin', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_plugin_ajax_action' ),
));
});
// PHP: AJAX handler
add_action( 'wp_ajax_my_plugin_do_thing', function() {
check_ajax_referer( 'my_plugin_ajax_action', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$result = do_something_useful( intval( $_POST['item_id'] ) );
wp_send_json_success( $result );
});
// JavaScript:
jQuery( document ).ready( function( $ ) {
$( '#my-button' ).on( 'click', function() {
$.post( MyPlugin.ajaxurl, {
action: 'my_plugin_do_thing',
nonce: MyPlugin.nonce,
item_id: 42,
}, function( response ) {
if ( response.success ) {
alert( 'Done!' );
}
});
});
});
The nonce is passed from PHP to JavaScript via wp_localize_script(). This keeps the nonce out of the HTML source (it ends up in a <script> tag as a JavaScript variable) and avoids URL exposure.
Pattern 3: Bulk Action Links in List Tables
When generating action links in admin list tables, each link should have its own nonce with a specific action:
function column_actions( $item ) {
$delete_url = wp_nonce_url(
admin_url( 'admin.php?action=my_plugin_delete&item=' . $item->id ),
'my_plugin_delete_' . $item->id
);
$edit_url = admin_url( 'admin.php?page=my-plugin-edit&item=' . $item->id );
return sprintf(
'<a href="%s">Edit</a> | <a href="%s" onclick="return confirm(\'Delete?\')">Delete</a>',
esc_url( $edit_url ),
esc_url( $delete_url )
);
}
Each delete link has a nonce specific to the item ID. Stealing the nonce for item 42 does not let an attacker delete item 43.
Pattern 4: Front-End Forms for Logged-Out Users
Since nonces offer no CSRF protection for logged-out users, you need supplementary measures:
// Render form with honeypot and nonce
function render_public_form() {
?>
<form method="post" id="public-form">
<?php wp_nonce_field( 'public_form_submit', '_public_nonce' ); ?>
<!-- Honeypot: hidden field that bots fill in -->
<div style="position: absolute; left: -9999px;">
<input type="text" name="website_url_confirm" value="" tabindex="-1" autocomplete="off" />
</div>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
<?php
}
// Handle submission
function handle_public_form() {
// Honeypot check
if ( ! empty( $_POST['website_url_confirm'] ) ) {
// Bot detected, silently discard
wp_redirect( home_url( '/thank-you/' ) );
exit;
}
// Nonce check (still useful for logged-in users)
if ( ! wp_verify_nonce( $_POST['_public_nonce'], 'public_form_submit' ) ) {
wp_die( 'Form expired. Please go back and try again.' );
}
// Rate limiting by IP
$ip = $_SERVER['REMOTE_ADDR'];
$transient_key = 'form_limit_' . md5( $ip );
if ( get_transient( $transient_key ) ) {
wp_die( 'Too many submissions. Please wait a few minutes.' );
}
set_transient( $transient_key, true, 60 );
// Process the submission
$email = sanitize_email( $_POST['email'] );
// ... save to database
}
The nonce still provides value here: it prevents replay attacks from logged-in users, and it provides expiry checking (the form cannot be submitted with a 3-day-old cached page). But the real protection for anonymous users comes from the honeypot and rate limiting.
Alternatives and Supplements to WordPress Nonces
Double Submit Cookie
The double submit cookie pattern provides CSRF protection without server-side state. The server sets a random value in a cookie and also includes that value in the form. On submission, the server checks that the cookie value and the form value match.
This works because an attacker on a different origin can trigger the browser to send cookies with a request, but cannot read the cookie value to include it in the form data. The same-origin policy prevents the attacker from accessing the cookie.
WordPress does not use this pattern natively, but it can be implemented in plugins:
// Set the CSRF cookie
function set_csrf_cookie() {
if ( ! isset( $_COOKIE['csrf_token'] ) ) {
$token = bin2hex( random_bytes( 32 ) );
setcookie( 'csrf_token', $token, 0, '/', '', true, true );
}
}
// Verify in handler
function verify_double_submit() {
$cookie_token = $_COOKIE['csrf_token'] ?? '';
$form_token = $_POST['csrf_token'] ?? '';
return hash_equals( $cookie_token, $form_token );
}
This pattern has advantages for anonymous users: each visitor gets a unique cookie, so the shared-nonce-for-guests problem disappears. The trade-off is that you need to manage cookie lifetimes and handle cookie-disabled browsers.
SameSite Cookie Attribute
Modern browsers support the SameSite attribute on cookies, which provides built-in CSRF protection at the browser level. When a cookie has SameSite=Strict or SameSite=Lax, the browser will not include it in cross-origin requests.
WordPress sets the SameSite attribute on its authentication cookies as of WordPress 5.7. With SameSite=Lax (the default), cookies are sent with top-level GET navigations but not with cross-origin POST requests, form submissions from other sites, or AJAX calls. This means that even without a nonce, a cross-origin POST to a WordPress site will not include authentication cookies, and the request will be treated as unauthenticated.
However, SameSite=Lax does allow cookies on top-level GET navigations. An attacker can still craft a link like https://example.com/wp-admin/admin.php?action=delete&id=42 and trick a user into clicking it. The GET request will include cookies. This is why nonces are still needed for GET-based actions, even with SameSite cookies.
SameSite=Strict would block cookies on all cross-origin requests, including navigations, but WordPress does not use Strict by default because it breaks legitimate flows (clicking a link to your own site from an email or search result would not include cookies, requiring re-authentication).
Origin and Referer Header Validation
Checking the Origin or Referer header of incoming requests is another CSRF mitigation. If the request comes from a different origin than expected, reject it:
function verify_origin() {
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ( ! $origin ) {
$origin = $_SERVER['HTTP_REFERER'] ?? '';
}
$allowed = home_url();
return strpos( $origin, $allowed ) === 0;
}
This approach has caveats. Some browsers strip the Referer header for privacy. Some network proxies strip it. The Origin header is only sent with POST requests and CORS requests, not with simple GET requests. Relying solely on header checks is fragile, but they make a useful additional layer.
Content Security Policy (CSP)
A Content Security Policy that restricts form submissions can prevent CSRF in a different way:
Content-Security-Policy: form-action 'self'
This tells the browser to only allow forms on your site to submit to your own origin. A malicious page on another origin cannot submit a form to your site because the browser will block it before the request is sent.
CSP form-action is not universally supported in older browsers and does not protect against AJAX-based CSRF (which is covered by CORS). But for form-based CSRF, it provides a strong browser-enforced defense.
Token-Based Authentication for APIs
For REST API endpoints, consider token-based authentication instead of cookie-based authentication with nonces. Application Passwords (added in WordPress 5.6) provide per-application credentials that do not require nonces:
// Client sends credentials via Basic Auth header
curl -u "username:xxxx xxxx xxxx xxxx xxxx xxxx" \
-X POST \
https://example.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-d '{"title":"New Post","status":"draft"}'
Application Passwords are transmitted with each request and do not depend on browser cookies. There is no CSRF vector because the attacker would need to know the password to forge a request. The nonce system is bypassed entirely.
For third-party integrations, OAuth 2.0 (available via plugins) provides scoped access tokens with refresh mechanisms. These are strictly superior to cookie-plus-nonce authentication for API consumers.
Deep Dive: The HMAC Construction
The nonce’s core security property comes from the HMAC construction. Let us examine exactly what is being computed.
The input string is: $i . '|' . $action . '|' . $uid . '|' . $token
For a concrete example, suppose:
– Tick value ($i): 52648412
– Action: update-post_123
– User ID: 7
– Session token: a1b2c3d4e5f67890abcdef1234567890
The input string is: 52648412|update-post_123|7|a1b2c3d4e5f67890abcdef1234567890
This is passed to hash_hmac( 'md5', $data, $salt ) where $salt is the concatenation of NONCE_KEY and NONCE_SALT from wp-config.php.
HMAC works by XORing the key with two different constants (ipad and opad), then performing two rounds of hashing:
HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m))
Where H is MD5, K’ is the key padded or hashed to the block size, and m is the message.
The security properties of HMAC are:
Unforgeability: Without knowing the key (NONCE_KEY + NONCE_SALT), an attacker cannot produce a valid HMAC for any input, even if they know valid HMAC outputs for other inputs.
No key recovery: Observing HMAC outputs does not reveal the key.
Pseudo-randomness: HMAC outputs are computationally indistinguishable from random strings.
These properties hold even when MD5 is used as the underlying hash function. The known collision attacks on bare MD5 do not apply to HMAC-MD5 because HMAC’s security proof does not depend on collision resistance. It depends on the compression function being a pseudorandom function, which MD5’s compression function still is, as far as published research indicates.
Entropy of the 10-Character Nonce
The full HMAC-MD5 output is 128 bits (32 hex characters). WordPress takes 10 hex characters from it, giving 40 bits of entropy. This means there are 2^40 (roughly 1.1 trillion) possible nonce values.
Is 40 bits enough? For an online brute-force attack where the attacker must submit each guess to the server, yes. At 1000 guesses per second, it would take approximately 34 years to exhaust the space. At 1 million guesses per second (which would constitute an obvious DoS attack), it would take about 12 days. Since nonces expire in 24 hours at most, the attacker cannot complete the search in time.
For offline attacks (where the attacker has the HMAC key and can compute hashes locally), 40 bits is trivially breakable. But if the attacker has the HMAC key, they can compute valid nonces directly without brute-forcing. The key compromise is the real problem in that scenario, not the nonce length.
Why the Pipe Separator Matters
The inputs are concatenated with pipe characters: $i . '|' . $action . '|' . $uid . '|' . $token. Without separators, the input would be ambiguous. Consider:
– Tick 123, action “abc”, user 45, token “def” produces “123abc45def”
– Tick 1234, action “bc4”, user 5, token “def” produces “1234bc45def”
Wait. Those are different, but other combinations could collide. With pipes: “123|abc|45|def” vs “1234|bc4|5|def” are clearly different. The separator ensures that each component occupies a distinct position in the input string, preventing cross-component collisions.
This is a standard practice in HMAC-based token construction. The OWASP CSRF Prevention Cheat Sheet recommends similar structured inputs for token generation.
Nonces and Multisite
In a WordPress Multisite installation, nonces work per-site because each site can have different secret keys. However, if all sites in the network share the same wp-config.php (which they do by default), they share the same NONCE_KEY and NONCE_SALT.
This means a nonce generated on Site A might be valid on Site B if the user ID, session token, action, and tick all match. In practice, session tokens usually differ between sites because cookies are scoped per domain. But for super admins who have sessions on multiple sites, there is a theoretical risk of cross-site nonce reuse within the same network.
The wp_salt() function does not incorporate the site ID or blog ID into the salt. If you need site-specific nonces in a Multisite context, include the blog ID in the action string:
$nonce = wp_create_nonce( 'my_action_blog_' . get_current_blog_id() );
Testing Nonces in Automated Tests
WordPress nonces depend on time, user, and session state, which makes them tricky to test. The WordPress test suite provides utilities for this:
class Test_My_Plugin extends WP_UnitTestCase {
public function test_form_submission_with_valid_nonce() {
// Create a user and set as current
$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $user_id );
// Generate a nonce
$nonce = wp_create_nonce( 'my_plugin_save_settings' );
// Simulate form submission
$_POST['_my_plugin_nonce'] = $nonce;
$_POST['my_option'] = 'test_value';
// Call the handler
my_plugin_handle_save();
// Assert the option was saved
$this->assertEquals( 'test_value', get_option( 'my_plugin_option' ) );
}
public function test_form_submission_with_invalid_nonce() {
$user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $user_id );
$_POST['_my_plugin_nonce'] = 'invalid_nonce_value';
$_POST['my_option'] = 'malicious_value';
// Expect wp_die to be called
$this->expectException( WPDieException::class );
my_plugin_handle_save();
}
public function test_form_submission_without_capability() {
$user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
wp_set_current_user( $user_id );
$nonce = wp_create_nonce( 'my_plugin_save_settings' );
$_POST['_my_plugin_nonce'] = $nonce;
$_POST['my_option'] = 'subscriber_value';
$this->expectException( WPDieException::class );
my_plugin_handle_save();
}
}
Testing the tick system requires either mocking time() (which is difficult in PHP without a library like php-timecop or runkit) or using WordPress’s built-in time travel. The nonce_life filter can help: set a very short nonce lifetime in tests to verify expiry behavior.
public function test_expired_nonce() {
$user_id = $this->factory->user->create();
wp_set_current_user( $user_id );
// Set a very short nonce lifetime
add_filter( 'nonce_life', function() { return 1; } ); // 1 second
$nonce = wp_create_nonce( 'test_action' );
// Wait for the nonce to expire
sleep( 2 );
$result = wp_verify_nonce( $nonce, 'test_action' );
$this->assertFalse( $result );
}
The Pluggable Function Angle
All three core nonce functions (wp_create_nonce(), wp_verify_nonce(), and wp_nonce_tick()) are defined in wp-includes/pluggable.php and wrapped in if ( ! function_exists() ) guards. This means a plugin (specifically, a must-use plugin loaded before pluggable.php) can replace the entire nonce implementation.
This is a powerful but dangerous capability. A security plugin might replace the nonce system with one that uses longer tokens, different hash algorithms, or server-side nonce storage for true single-use behavior. But any bug in the replacement will break every nonce-dependent feature in WordPress: the admin, AJAX, REST API, post editing, plugin management, and more.
If you are considering replacing pluggable nonce functions, think carefully about:
1. Every action in WordPress core that creates or verifies nonces.
2. Backward compatibility with the return value semantics (1, 2, or false).
3. The guest user behavior and the nonce_user_logged_out filter.
4. Interaction with the Heartbeat API and REST API nonce middleware.
5. Caching plugins that may have optimized for the default nonce behavior.
A safer approach is to use the nonce_life filter for lifetime changes and to build supplementary security layers on top of, rather than replacing, the core nonce system.
Summary of Key Facts
WordPress nonces are HMAC-based tokens, not random numbers. They are deterministic: given the same inputs, the same nonce is produced every time. They are not single-use. They are valid for a window of 12 to 24 hours by default, determined by a tick system that divides time into fixed periods.
The nonce is bound to four values: the current time tick, the action string, the user ID, and the session token. Changing any of these invalidates the nonce. For unauthenticated users, the user ID is 0 and the session token is empty, meaning all guests share identical nonces for any given action and tick.
The HMAC construction uses MD5 by default with keys from wp-config.php. The output is truncated to 10 hexadecimal characters (40 bits), which is sufficient for online brute-force resistance within the nonce’s validity window.
Nonces are necessary but not sufficient for security. They must be paired with capability checks (current_user_can()), input sanitization, and output escaping. For anonymous users, nonces should be supplemented with honeypots, rate limiting, or CAPTCHA. For API consumers, consider Application Passwords or OAuth instead of cookie-plus-nonce authentication.
The nonce system can be customized via the nonce_life filter (per-action since WordPress 6.1), the nonce_user_logged_out filter (for guest user handling), and the pluggable function override mechanism. Customization should be approached with caution, as the nonce system is deeply integrated into WordPress core functionality.
Keep your NONCE_KEY and NONCE_SALT secret and unique. If they are compromised, an attacker can forge valid nonces for any user, action, and time period. Rotate these keys periodically and immediately after any suspected breach. You can generate new keys at https://api.wordpress.org/secret-key/1.1/salt/.
David Okonkwo
Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.