WordPress Admin AJAX Example: Build a Secure Logged-In Action
Build a secure logged-in Admin AJAX action with nonce checks, capability checks, scoped scripts, sanitized input, and JSON responses.
Published
April 19, 2026
Reading Time
3 min read
Updated
April 19, 2026

Hardening Notes
Baselines, access reduction, and default settings that stand up in production.
Best For
Teams preparing, launching, or maintaining WordPress as a backend service in a production stack.
Primary Topics
Editorial Focus
Control Ledger: Baselines, access reduction, and default settings that stand up in production. Updated on April 19, 2026.
Full Report
Last reviewed: April 19, 2026
Admin AJAX is still widely used in WordPress plugins, even when REST API routes would be cleaner for new features. The risk is that many examples show only admin-ajax.php and an action name, but skip nonce verification, capability checks, input sanitization, output escaping, and proper JSON responses.
This guide shows a secure logged-in Admin AJAX action. It is written for plugin developers who need to support existing WordPress admin workflows without creating a weak write surface.
Who this guide is for
This article is for WordPress plugin developers, agencies maintaining legacy plugins, and security reviewers auditing AJAX handlers in custom code.
Admin AJAX or REST API?
For new application-style APIs, a custom REST route is often the better choice. Admin AJAX still makes sense for small admin interactions, legacy screens, existing jQuery-based plugins, and cases where the request is tightly coupled to wp-admin behavior.
If the action changes data, treat it like any privileged write request: check capability, verify nonce, validate input, and return a predictable response.
Register the AJAX handler
<?php
/**
* Plugin Name: VulnWP Admin AJAX Example
* Description: Secure logged-in Admin AJAX action example.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'wp_ajax_vulnwp_save_note', 'vulnwp_ajax_save_note' );
function vulnwp_ajax_save_note() {
check_ajax_referer( 'vulnwp_save_note', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
array( 'message' => 'You are not allowed to save this note.' ),
403
);
}
$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
$note = isset( $_POST['note'] )
? sanitize_textarea_field( wp_unslash( $_POST['note'] ) )
: '';
if ( ! $post_id || '' === $note ) {
wp_send_json_error(
array( 'message' => 'Post ID and note are required.' ),
400
);
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_send_json_error(
array( 'message' => 'You cannot edit this post.' ),
403
);
}
update_post_meta( $post_id, 'vulnwp_internal_note', $note );
wp_send_json_success(
array(
'message' => 'Note saved.',
'postId' => $post_id,
)
);
}
Enqueue the script and pass the nonce
add_action( 'admin_enqueue_scripts', 'vulnwp_enqueue_admin_ajax_script' );
function vulnwp_enqueue_admin_ajax_script( $hook_suffix ) {
if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) {
return;
}
wp_enqueue_script(
'vulnwp-admin-note',
plugins_url( 'admin-note.js', __FILE__ ),
array(),
'1.0.0',
true
);
wp_add_inline_script(
'vulnwp-admin-note',
'window.vulnwpAdminNote = ' . wp_json_encode(
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'vulnwp_save_note' ),
)
) . ';',
'before'
);
}
The script is only loaded on post edit screens. This is better than loading admin JavaScript everywhere and hoping unused code does not matter.
Client request example
async function saveInternalNote( postId, note ) {
const body = new URLSearchParams( {
action: 'vulnwp_save_note',
nonce: window.vulnwpAdminNote.nonce,
post_id: String( postId ),
note,
} );
const response = await fetch( window.vulnwpAdminNote.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
} );
const data = await response.json();
if ( ! response.ok || ! data.success ) {
throw new Error( data.data?.message || 'Save failed.' );
}
return data.data;
}
Why each control exists
wp_ajax_vulnwp_save_noteonly handles logged-in users.check_ajax_referer()protects against CSRF.current_user_can()verifies the user can perform the action.absint()andsanitize_textarea_field()normalize input.wp_send_json_success()andwp_send_json_error()produce predictable responses and stop execution.
Testing the handler
Test Admin AJAX actions with real user roles, not only with an administrator account. A safe handler should reject missing nonces, invalid post IDs, users without edit_posts, and users who can edit some posts but not the target post.
# Missing nonce should fail.
curl -s -X POST https://example.com/wp-admin/admin-ajax.php \
-d 'action=vulnwp_save_note&post_id=123¬e=test'
The response should not expose stack traces, raw SQL, filesystem paths, or internal implementation details. Keep responses useful for the UI but boring from an attacker’s perspective.
Public AJAX warning
If the action must work for logged-out visitors, registering wp_ajax_nopriv_{$action} changes the risk profile. Public AJAX endpoints need rate limiting, stricter validation, anti-spam controls, and careful output design. Do not take an admin-only handler and simply add the nopriv hook.
Common mistakes
- Using only a nonce. A nonce is not authorization. Always check capability.
- Using only a capability. Capability checks do not replace CSRF protection.
- Registering
wp_ajax_noprivaccidentally. Public AJAX handlers need a much stricter design. - Echoing arbitrary JSON manually. Use WordPress JSON helpers.
- Loading scripts across all admin pages. Scope scripts to the screen that needs them.
Related reading
Read the WordPress nonce example for deeper CSRF context and the register_rest_route example when a REST endpoint is the better fit.


