DEV3 Technologies home

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' );

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_unslash is required before sanitising $_POST values.
  • 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.

Related snippets