Independent Editorial DeskWordPress Releases, Builds, and Operations
Back to Archive
Hardening NotesImplementation Notes

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

Abstract WordPress admin AJAX request flow secured with a lock and permission boundary.
Control LedgerHardening Notes

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

Hardening NotesImplementation Notes

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_note only handles logged-in users.
  • check_ajax_referer() protects against CSRF.
  • current_user_can() verifies the user can perform the action.
  • absint() and sanitize_textarea_field() normalize input.
  • wp_send_json_success() and wp_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&note=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_nopriv accidentally. 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.

References and further reading

Popular Guides

Popular WordPress guides to read next.

These articles connect recurring production concerns: implementation details, updates, troubleshooting, recovery paths, and operational cleanup.

Continue Reading

More from the archive.

Diagnostic dashboard scene representing a WordPress Site Health review before major updates.
01Build Pattern
Implementation Notes

Build Pattern

Extension points, code paths, and implementation choices that should survive contact with production.

May 21, 2026 · 3 min read

WordPress Site Health Check Before Major Updates: What to Review First

A pre-update WordPress Site Health checklist covering loopbacks, connectivity, debug settings, and environment readiness.

Structured data and route review scene representing permalink validation after a WordPress migration.
02Build Pattern
Implementation Notes

Build Pattern

Extension points, code paths, and implementation choices that should survive contact with production.

May 21, 2026 · 3 min read

WordPress Permalink Checklist After Migration: Catch URL Problems Early

A post-migration WordPress permalink checklist for checking rewrite rules, post URLs, archives, and redirect noise.

Technical media workspace representing image preparation and optimization before upload to WordPress.
03Build Pattern
Implementation Notes

Build Pattern

Extension points, code paths, and implementation choices that should survive contact with production.

May 21, 2026 · 3 min read

WordPress Image Optimization Checklist: What to Fix Before Upload

A practical WordPress image optimization checklist covering dimensions, compression, formats, and Media settings before upload.