WordPress AJAX Security: Nonces with wp_create_nonce and check_ajax_referer

WordPress’s AJAX system — built around admin-ajax.php and the wp_ajax_* / wp_ajax_nopriv_* action hooks — is straightforward to use but equally straightforward to abuse if you skip nonce verification. Without it, any JavaScript on any page can trigger your AJAX handler with arbitrary data, and any authenticated user (or unauthenticated visitor for nopriv handlers) can call the endpoint directly from a browser or curl. A nonce — a one-time-use token generated server-side and embedded in the page — ties each AJAX request to a specific browser session and a specific action name. WordPress nonces are not truly one-time (they have a 12-hour lifespan by default), but they expire on logout and are keyed to the user ID and the action string, making replay attacks from different sessions impossible. The complete pattern has three parts: generate the nonce in PHP and pass it to JavaScript via wp_localize_script(); send the nonce with every AJAX request; verify it server-side with check_ajax_referer() before doing anything else. This article shows all three, plus the difference between check_ajax_referer() and wp_verify_nonce().

Problem: Your WordPress AJAX handler processes requests without verifying that they came from a legitimate page load in the current user's session, making it possible to forge requests from external scripts.

Solution: Generate a nonce with wp_create_nonce(), pass it to JavaScript via wp_localize_script(), include it in every AJAX request, and verify it server-side with check_ajax_referer() as the very first line of the handler.

PHP — enqueue, localise (pass nonce to JS), and register the AJAX handler:

<?php
add_action( 'wp_enqueue_scripts', 'enqueue_my_ajax_script' );

function enqueue_my_ajax_script() {
    wp_enqueue_script( 'my-ajax', get_template_directory_uri() . '/js/my-ajax.js',
                       [ 'jquery' ], null, true );

    wp_localize_script( 'my-ajax', 'myAjax', [
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'my_ajax_action' ),
    ] );
}

// Handler for logged-in users
add_action( 'wp_ajax_my_ajax_action',        'handle_my_ajax' );
// Handler for non-logged-in visitors
add_action( 'wp_ajax_nopriv_my_ajax_action', 'handle_my_ajax' );

function handle_my_ajax() {
    // 1. Verify nonce — dies with -1 if invalid
    check_ajax_referer( 'my_ajax_action', 'nonce' );

    // 2. Validate and sanitise input
    $item_id = absint( $_POST['item_id'] ?? 0 );
    if ( ! $item_id ) {
        wp_send_json_error( 'Invalid item ID', 400 );
    }

    // 3. Do the work
    $post = get_post( $item_id );
    if ( ! $post ) {
        wp_send_json_error( 'Post not found', 404 );
    }

    wp_send_json_success( [
        'id'    => $post->ID,
        'title' => get_the_title( $post->ID ),
    ] );
}

JavaScript — send the nonce with every request:

jQuery( function ( $ ) {
    $( '#load-item' ).on( 'click', function () {
        $.post( myAjax.ajaxUrl, {
            action:  'my_ajax_action',
            nonce:   myAjax.nonce,        // nonce from wp_localize_script
            item_id: $( this ).data( 'id' ),
        }, function ( response ) {
            if ( response.success ) {
                console.log( response.data.title );
            } else {
                console.error( response.data );
            }
        } );
    } );
} );

NOTE: check_ajax_referer() calls wp_die( -1 ) on failure, which immediately terminates the request with HTTP 200 and body -1. If you need finer control over the error response — for example, returning HTTP 403 with a JSON body — use wp_verify_nonce() directly and send your own error response: if ( ! wp_verify_nonce( $_POST['nonce'], 'my_ajax_action' ) ) { wp_send_json_error( 'Nonce expired', 403 ); }.