Nonce and capability checked AJAX endpoint
A WordPress admin AJAX action that verifies a nonce, checks a capability, sanitises input and answers with JSON.
WordPressAPIsajaxnoncecapabilityjson
Code
<?php
/**
* Save a short note through admin-ajax.
*
* @package Acme_Core
*/
defined( 'ABSPATH' ) || exit;
function acme_ajax_assets(): void {
wp_enqueue_script( 'acme-ajax-note', plugin_dir_url( __FILE__ ) . '../assets/ajax-note.js', array(), '1.0.0', array( 'in_footer' => true ) );
wp_localize_script( 'acme-ajax-note', 'acmeNote', array(
'url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'acme_save_note' ),
) );
}
add_action( 'admin_enqueue_scripts', 'acme_ajax_assets' );
function acme_ajax_save_note(): void {
// 1. Nonce: dies with -1 / 403 when missing or wrong.
check_ajax_referer( 'acme_save_note', 'nonce' );
// 2. Capability: who is allowed to do this?
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do that.', 'acme-core' ) ), 403 );
}
// 3. Sanitise and validate every input.
$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 || ! current_user_can( 'edit_post', $post_id ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid post.', 'acme-core' ) ), 400 );
}
if ( '' === $note || mb_strlen( $note ) > 500 ) {
wp_send_json_error( array( 'message' => __( 'Enter a note up to 500 characters.', 'acme-core' ) ), 422 );
}
update_post_meta( $post_id, '_acme_note', $note );
wp_send_json_success( array( 'note' => esc_html( $note ) ) );
}
// Only logged-in users: no wp_ajax_nopriv_ hook is registered on purpose.
add_action( 'wp_ajax_acme_save_note', 'acme_ajax_save_note' );
async function acmeSaveNote(postId, note) {
const body = new URLSearchParams({
action: 'acme_save_note',
nonce: acmeNote.nonce,
post_id: String(postId),
note,
});
const res = await fetch(acmeNote.url, { method: 'POST', credentials: 'same-origin', body });
const json = await res.json();
if (!json.success) {
throw new Error(json.data?.message ?? 'Request failed');
}
return json.data.note;
}
How it works
What it solves
AJAX calls are public entry points. Without a nonce, a capability check and sanitising, any site visitor, or any malicious page the admin visits, can change data. This snippet shows the three guards in the right order.
How it works
The script receives a freshly created nonce through wp_localize_script. The PHP handler calls check_ajax_referer, then verifies the user's capability, then validates inputs: an integer post id with an object-level edit_post check and a length-limited, sanitised string. Errors use wp_send_json_error with proper HTTP status codes, and success returns escaped data.
Only the logged-in hook is registered, so anonymous requests never reach the callback.
Adapting it
For visitors, add the matching wp_ajax_nopriv_ hook but then drop the capability check in favour of rate limiting and stricter validation. Rename the action, meta key and limits. On sites that prefer REST, use the same three guards in a permission_callback and an argument schema instead.
Pitfalls
- A nonce proves intent, not permission; both checks are needed.
wp_unslashis required before sanitising$_POSTvalues.- Nonces last about a day; long-lived pages should handle a 403 by refreshing.
- Return only what the client needs.
How to use it
Put the PHP in a plugin or theme inc/ folder and the JS in assets/.
Call acmeSaveNote( 42, 'Remember to update' ).then( ... ) from your own admin code.
Use the browser network tab to confirm a missing nonce returns an error.
Licence: GPL-2.0-or-later. This snippet comes from WordPress code and is GPL-2.0-or-later.