Cross-Site Request Forgery (CSRF) attacks trick users into performing unwanted actions on your site:
- Attacker creates malicious link or form
- User clicks while logged into your site
- Unwanted action executes with user's permissions
- User's account compromised, data deleted, or settings changed
Without nonce validation, attackers can make users do anything they have permission to do.
Nonce = "Number used ONCE"
In WordPress, nonces are:
- Temporary tokens tied to user, action, and time
- Valid for 24 hours (12-24 hour window)
- Prevent CSRF attacks
- Verify requests originated from your site
function handle_delete_post() {
if ( isset( $_POST['post_id'])) {
wp_delete_post( $_POST['post_id'], true);
}
}
add_action( 'admin_post_delete_post', 'handle_delete_post');Attack Example: Attacker creates malicious page:
<form method="post" action="https://yoursite.com/wp-admin/admin-post.php">
<input type="hidden" name="action" value="delete_post">
<input type="hidden" name="post_id" value="123">
</form>
<script>document.forms[0].submit();</script>When logged-in admin visits this page, post #123 is deleted automatically!
// Delete all posts
<img src="https://yoursite.com/wp-admin/?action=delete_all&user_id=1">
// Change admin password
<form action="https://yoursite.com/wp-admin/profile.php" method="post">
<input name="pass1" value="hacked123">
<input name="pass2" value="hacked123">
</form>
// Install malicious plugin
// Transfer money
// Delete users
// ... anything the user can do!function handle_delete_post(): void {
// CHECK NONCE FIRST!
if ( !isset( $_POST['_wpnonce']) || !wp_verify_nonce( $_POST['_wpnonce'], 'delete_post_action')) {
wp_die( 'Security check failed');
}
// Check permissions
if ( !current_user_can( 'delete_posts')) {
wp_die( 'Permission denied');
}
// Now safe to process
wp_delete_post( $_POST['post_id'], true);
}<form method="post" action="<?php echo admin_url( 'admin-post.php'); ?>">
<input type="hidden" name="action" value="delete_post">
<input type="hidden" name="post_id" value="123">
<?php wp_nonce_field( 'delete_post_action'); ?>
<button type="submit">Delete</button>
</form>wp_nonce_field() generates:
<input type="hidden" name="_wpnonce" value="a1b2c3d4e5">
<input type="hidden" name="_wp_http_referer" value="/wp-admin/...">// Generate nonce URL
$delete_url = wp_nonce_url(
admin_url( 'admin-post.php?action=delete_post&post_id=123'),
'delete_post_123',
'delete_nonce'
);
// Result: /wp-admin/admin-post.php?action=delete_post&post_id=123&delete_nonce=a1b2c3d4
// Verify
if ( !wp_verify_nonce( $_GET['delete_nonce'], 'delete_post_123')) {
wp_die( 'Invalid nonce');
}// Generate nonce for JavaScript
<button
data-nonce="<?php echo wp_create_nonce( 'update_status'); ?>"
data-post-id="123">
Update
</button>
<script>
jQuery.ajax( {
url: ajaxurl,
method: 'POST',
data: {
action: 'update_status',
post_id: 123,
_ajax_nonce: nonce
}
});
</script>
// Verify in PHP
function handle_ajax() {
if ( !wp_verify_nonce($_POST['_ajax_nonce'], 'update_status')) {
wp_send_json_error('Invalid nonce', 403);
}
// Process...
}wp_nonce_field($action, $name, $referer, $echo);
// Example
wp_nonce_field('save_settings', 'my_nonce');
// Generates hidden input fields$nonce = wp_create_nonce($action);
// Example
$nonce = wp_create_nonce('delete_post_123');
// Returns: 'a1b2c3d4e5'$result = wp_verify_nonce($nonce, $action);
// Returns:
// false = invalid/expired
// 1 = valid (0-12 hours old)
// 2 = valid (12-24 hours old)
// Example
if (wp_verify_nonce($_POST['_wpnonce'], 'my_action') === false) {
wp_die('Invalid nonce');
}check_admin_referer($action, $name);
// Dies automatically if nonce invalid
// Shorthand for verify + die
// Example
check_admin_referer('save_settings', '_wpnonce');
// If invalid, dies with error$url = wp_nonce_url($bare_url, $action, $name);
// Example
$url = wp_nonce_url(
admin_url('admin-post.php?action=delete'),
'delete_post_123',
'nonce'
);
// Result: ...?action=delete&nonce=a1b2c3d4// BAD: Too generic
wp_create_nonce('action');
wp_create_nonce('form');
wp_create_nonce('save');
// GOOD: Specific and descriptive
wp_create_nonce('delete_post_123');
wp_create_nonce('save_user_settings_456');
wp_create_nonce('update_plugin_options');// BAD: Same nonce for all posts
wp_create_nonce('delete_post');
// GOOD: Unique nonce per post
wp_create_nonce('delete_post_' . $post_id);This ensures nonce for post #1 can't be used to delete post #2!
// Nonce alone is NOT enough!
if (!wp_verify_nonce($_POST['nonce'], 'delete_post')) {
wp_die('Invalid nonce');
}
// MUST also check permissions
if (!current_user_can('delete_posts')) {
wp_die('Permission denied');
}
// Now safe
wp_delete_post($post_id);Nonce verifies the request came from your site.
Permission check verifies the user is allowed to do the action.
// BAD: GET request to delete
<a href="?action=delete&id=123">Delete</a>
// GOOD: POST request with form
<form method="post">
<?php wp_nonce_field('delete_123'); ?>
<button type="submit">Delete</button>
</form>GET requests should be read-only. DELETE/UPDATE must use POST.
$result = wp_verify_nonce($_POST['nonce'], 'long_form');
if ($result === false) {
// Expired or invalid
wp_die('Security check failed. Please refresh the page and try again.');
}
if ($result === 2) {
// Valid but aging (12-24 hours old)
// Consider logging or warning
error_log('Aging nonce used by user ' . get_current_user_id());
}
// Process form...// BAD: Nonce in form but never checked!
function handle_form() {
update_option('setting', $_POST['value']); // No check!
}Always verify!
// Form generates with action 'save_settings'
wp_nonce_field('save_settings');
// But handler verifies 'update_settings' - MISMATCH!
wp_verify_nonce($_POST['_wpnonce'], 'update_settings'); // Always fails!Action names must match exactly!
// BAD: Calling verify but not checking result
wp_verify_nonce($_POST['nonce'], 'action');
update_option('value', $_POST['value']); // Runs regardless!
// GOOD: Check the result
if (!wp_verify_nonce($_POST['nonce'], 'action')) {
wp_die('Invalid nonce');
}
update_option('value', $_POST['value']);// BAD: Valid nonce but no permission check
if (wp_verify_nonce($_POST['nonce'], 'admin_action')) {
delete_option('critical_setting'); // Any user with nonce can do this!
}
// GOOD: Both nonce AND permission
if (wp_verify_nonce($_POST['nonce'], 'admin_action') && current_user_can('manage_options')) {
delete_option('critical_setting');
}WordPress REST API has built-in nonce handling:
// REST API automatically checks nonces for authenticated requests
register_rest_route('myplugin/v1', '/posts', [
'methods' => 'POST',
'callback' => 'create_post',
'permission_callback' => function() {
return current_user_can('edit_posts');
}
]);For non-REST clients, add nonce to request:
fetch('/wp-json/myplugin/v1/posts', {
method: 'POST',
headers: {
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify({...})
});// 1. Try without nonce
// Should: Fail with error
// 2. Try with invalid nonce
$_POST['_wpnonce'] = 'invalid123';
// Should: Fail with error
// 3. Try with expired nonce
$_POST['_wpnonce'] = wp_create_nonce('action');
// Wait 25 hours
// Should: Fail with error
// 4. Try with valid nonce
$_POST['_wpnonce'] = wp_create_nonce('action');
// Should: Succeedpublic function test_nonce_required() {
$_POST['action'] = 'delete_post';
$_POST['post_id'] = 123;
// No nonce
$this->expectException(WPDieException::class);
handle_delete_post();
}
public function test_invalid_nonce_rejected() {
$_POST['_wpnonce'] = 'invalid';
$_POST['action'] = 'delete_post';
$this->expectException(WPDieException::class);
handle_delete_post();
}
public function test_valid_nonce_accepted() {
$_POST['_wpnonce'] = wp_create_nonce('delete_post');
$_POST['post_id'] = 123;
// Should not throw
handle_delete_post();
}✅ ALWAYS validate nonces for state-changing actions
✅ Use wp_nonce_field() in forms
✅ Use wp_nonce_url() for links
✅ Use wp_create_nonce() for AJAX
✅ Check permissions in addition to nonce
✅ Use specific action names (include ID when relevant)
✅ Use POST for delete/update operations
✅ Handle expiration gracefully with helpful messages
❌ NEVER trust requests without nonce validation
❌ DON'T use generic action names
❌ DON'T skip permission checks
❌ DON'T use GET for state-changing actions
❌ DON'T create nonce but forget to verify
❌ DON'T reuse nonces across different actions
CSRF attacks are easy to execute and devastating.
Without nonces, an attacker can:
- Delete all your content
- Change passwords
- Install malicious plugins
- Transfer money
- Steal data
One missing nonce check = complete account takeover.
Always use nonces:
// Form: Generate nonce
<?php wp_nonce_field('my_action'); ?>
// Handler: Verify nonce
if (!wp_verify_nonce($_POST['_wpnonce'], 'my_action')) {
wp_die('Security check failed');
}
// Also check permissions!
if (!current_user_can('required_capability')) {
wp_die('Permission denied');
}
// Now safe to processTwo lines of code prevent catastrophic security breaches.
There is NO excuse for missing nonce validation.