Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 066 / 100

Writing secure AJAX and REST handlers: a practical guide

The most common plugin vulnerabilities.

Expona Admin7 min read

Introduction

AJAX and REST handlers are the backbone of modern WordPress plugins, powering dynamic user interactions without page reloads and enabling third-party integrations. Yet they're also where we see the most security breaches—not because the attack vectors are exotic, but because developers skip the fundamental checks that WordPress provides. Writing secure AJAX and REST handlers: a practical guide means understanding and implementing four core protections that save us from the majority of real-world exploits.

We've debugged plugins in production e-commerce sites where a missing nonce let attackers bulk-modify product pricing, and SaaS platforms where unsanitized input in REST endpoints created SQL injection paths. These weren't advanced zero-days; they were standard vulnerabilities that proper handler setup prevents entirely. Let's walk through what to do and what to avoid.

Missing permission checks

Permission checks are your first line of defense. They ensure that only users with the right capabilities can call your handler. Without them, any logged-in user—or sometimes anyone at all—can trigger functionality intended only for administrators or specific roles.

The most common mistake is checking whether a user is logged in, then assuming they can perform an action. WordPress capabilities are granular. A subscriber isn't an editor; a contributor can't delete posts. Your handlers must respect that hierarchy.

Consider a plugin that lets users export reports. The handler might look like this:

php
add_action( 'wp_ajax_export_report', function() {
    // Missing capability check!
    $data = get_option( 'sensitive_report_data' );
    wp_send_json( $data );
} );

Any logged-in user can now export reports, even if they shouldn't have access. The fix is straightforward:

php
add_action( 'wp_ajax_export_report', function() {
    // Check user can manage reports
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( 'Unauthorized', 403 );
    }
    
    $data = get_option( 'sensitive_report_data' );
    wp_send_json( $data );
} );

For REST endpoints, use the permission_callback parameter in your route registration:

php
register_rest_route( 'myapp/v1', '/reports', array(
    'methods'             => 'GET',
    'callback'            => function( $request ) {
        $data = get_option( 'sensitive_report_data' );
        return rest_ensure_response( $data );
    },
    'permission_callback' => function( $request ) {
        return current_user_can( 'manage_options' );
    },
) );

Use current_user_can() with role-appropriate capabilities. For custom functionality, register custom capabilities and assign them to roles during plugin activation. This keeps your permission logic testable and maintainable.

Missing nonce verification

A nonce (number used once) is a token that proves a request originated from your site, not from a cross-site forgery attack. Without nonce verification, attackers can craft links or forms that trick your users into triggering unwanted actions.

Imagine a plugin that lets admins clear the cache. An attacker emails a link like https://yoursite.com/wp-admin/admin-ajax.php?action=clear_cache. If you don't verify a nonce, the admin's browser will send that request with their credentials, and the cache clears. Now scale that to payment processing or user deletion.

Generate a nonce in your frontend template or form:

php
wp_nonce_field( 'clear_cache_nonce', 'cache_nonce' );

In JavaScript, include it in the AJAX call:

php
jQuery.post( ajaxurl, {
    action: 'clear_cache',
    nonce: jQuery( '#cache_nonce' ).val()
}, function( response ) {
    console.log( response );
} );

On the server, verify it immediately:

php
add_action( 'wp_ajax_clear_cache', function() {
    if ( ! isset( $_POST['nonce'] ) || 
         ! wp_verify_nonce( $_POST['nonce'], 'clear_cache_nonce' ) ) {
        wp_send_json_error( 'Invalid nonce', 403 );
    }
    
    wp_cache_flush();
    wp_send_json_success( 'Cache cleared' );
} );

For REST endpoints, WordPress automatically handles nonces if you configure it correctly. Add the nonce to the request header, and WordPress validates it:

php
// JavaScript
fetch( '/wp-json/myapp/v1/reports', {
    method: 'POST',
    headers: {
        'X-WP-Nonce': wpApiSettings.nonce
    },
    body: JSON.stringify( { /* data */ } )
} );

The REST API exposes nonces via wp.i18n in the block editor or via a nonce endpoint. Always pass and verify them.

Unsanitized input

Any data from the client—query strings, POST bodies, cookies—is untrusted. Sanitization cleans it and removes anything dangerous before you use it in queries, output, or file operations.

Unsanitized input is how SQL injection, XSS, and command injection vulnerabilities enter your code. A handler that receives user IDs and doesn't sanitize them can be tricked into returning data for the wrong user or crashing queries.

Consider a handler that fetches user metadata:

php
add_action( 'wp_ajax_get_user_data', function() {
    $user_id = $_POST['user_id'];  // Unsanitized!
    $data = get_user_meta( $user_id, 'profile', true );
    wp_send_json( $data );
} );

If $user_id contains non-numeric data, it could break database queries or bypass capability checks. Sanitize based on the expected type:

php
add_action( 'wp_ajax_get_user_data', function() {
    $user_id = isset( $_POST['user_id'] ) ? 
        absint( $_POST['user_id'] ) : 0;
    
    if ( ! $user_id ) {
        wp_send_json_error( 'Invalid user ID', 400 );
    }
    
    $data = get_user_meta( $user_id, 'profile', true );
    wp_send_json( $data );
} );

Use these sanitization functions for common types:

  • absint() for integers
  • sanitize_text_field() for plain text
  • sanitize_email() for email addresses
  • wp_kses_post() for HTML content that admins write
  • esc_url_raw() for URLs before storage

For REST endpoints, use the sanitize_callback and validate_callback parameters:

php
register_rest_route( 'myapp/v1', '/users/(?P\d+)', array(
    'methods'  => 'GET',
    'callback' => function( $request ) {
        $user_id = $request['id'];
        $data = get_user_meta( $user_id, 'profile', true );
        return rest_ensure_response( $data );
    },
    'args'     => array(
        'id' => array(
            'type'              => 'integer',
            'required'          => true,
            'validate_callback' => function( $param ) {
                return is_numeric( $param );
            },
        ),
    ),
) );

WordPress type-checks and validates against your schema automatically when you declare argument types in REST routes.

Unescaped output

Escaping converts data into a safe format for the context where it's used. Output escaping prevents injected JavaScript from running in browsers (XSS) and prevents data corruption in HTML, URLs, and attributes.

Data might be safe when stored but dangerous when rendered. If you fetch a post title and output it directly in JSON, an attacker could have stored JavaScript in that title. Escape before output.

A common pattern in AJAX handlers is building HTML responses:

php
add_action( 'wp_ajax_load_comments', function() {
    $post_id = absint( $_POST['post_id'] );
    $comments = get_comments( array( 'post_id' => $post_id ) );
    
    $html = '';
    foreach ( $comments as $comment ) {
        // Dangerous! Unescaped content
        $html .= '<div>' . $comment->comment_content . '</div>';
    }
    
    wp_send_json( array( 'html' => $html ) );
} );

The comment content could contain script tags. Escape it:

php
add_action( 'wp_ajax_load_comments', function() {
    $post_id = absint( $_POST['post_id'] );
    $comments = get_comments( array( 'post_id' => $post_id ) );
    
    $html = '';
    foreach ( $comments as $comment ) {
        // Safe: wp_kses_post escapes HTML
        $html .= '<div>' . wp_kses_post( $comment->comment_content ) . '</div>';
    }
    
    wp_send_json( array( 'html' => $html ) );
} );

Use these escaping functions:

  • esc_html() for HTML content with no tags
  • wp_kses_post() for post content with allowed HTML
  • esc_attr() for HTML attributes
  • esc_url() for URLs in href attributes
  • esc_js() for JavaScript strings (rarely needed in JSON responses)

In REST endpoints, WordPress REST responses are JSON-encoded, which provides some protection against XSS. However, escape data before returning it if it will be rendered client-side:

php
return rest_ensure_response( array(
    'title'   => wp_kses_post( $post->post_title ),
    'content' => wp_kses_post( $post->post_content ),
) );

Common mistakes

  • Checking only is_user_logged_in() instead of current_user_can(): Logged-in doesn't mean authorized. Use capabilities.
  • Nonces with long lifespans: WordPress nonces expire after 12-24 hours by default. That's intentional. Don't extend it without reason.
  • Trusting client-side validation: Sanitize on the server always. Client-side validation is for UX only.
  • Using wp_json_encode() then manually echoing it: Use wp_send_json() or wp_send_json_success() instead. They handle encoding and headers.
  • Assuming data is safe because it came from the database: Escape output context. Old data might have been stored unsafely.
  • Not testing with different user roles: Check handlers as subscriber, contributor, and editor. Permissions should vary.

Key takeaways

  • Always use current_user_can() with appropriate capabilities; never rely on login status alone.
  • Generate nonces in templates, pass them in requests, and verify them immediately in handlers.
  • Sanitize all input based on expected type before using it in queries, logic, or storage.
  • Escape output based on context (HTML, attributes, URLs) before sending it to clients.
  • Use WordPress helpers like wp_send_json(), absint(), wp_kses_post(), and esc_attr()—they exist to make security the default.
  • Test AJAX and REST endpoints with multiple user roles to verify permission boundaries actually work.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles