Nonces

Nonces prove a request came from your form for this user; wp_nonce_field, wp_verify_nonce and check_admin_referer do the work.

Syntaxwp_verify_nonce( string $nonce, string|int $action = -1 ): int|false

A nonce is a token derived from the user's session, an action string and a time window. Its one job is to prove that a request came from a form or link WordPress rendered for this user, which is what defeats cross-site request forgery: an attacker's page can make a browser POST to your site, but it cannot guess the token.

Producing one

wp_nonce_field( $action = -1, $name = '_wpnonce', $referer = true, $display = true ) prints two hidden inputs — the nonce, and the referer field unless you turn it off. For links rather than forms, wp_nonce_url( $actionurl, $action = -1, $name = '_wpnonce' ). Always pass a specific action string, ideally including the object ID: 'acme_delete_report_' . $id, not 'save'.

Checking one

wp_verify_nonce( $nonce, $action = -1 ) returns 1 if the nonce was issued in the first half of its lifespan, 2 in the second half, and false if it is invalid or expired. It returns rather than stops, so you must handle the failure yourself. check_admin_referer( $action = -1, $query_arg = '_wpnonce' ) wraps it for admin requests and calls wp_nonce_ays() then die() on failure. check_ajax_referer( $action = -1, $query_arg = false, $stop = true ) is the AJAX form; with no $query_arg it looks in $_REQUEST for _ajax_nonce, then _wpnonce.

What it does not cover

Permissions. Nonces are valid for whoever they were issued to, so pair every one with current_user_can(). Default lifespan is DAY_IN_SECONDS, filterable via nonce_life. Because they are session-bound they change on logout, which is why full-page caches must never store markup containing one.

Example

Example · php
<?php
/**
 * The form.
 */
function acme_render_settings_form() {
    ?>
    <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
        <input type="hidden" name="action" value="acme_save_settings">

        <?php
        // Shorthand: prints the _wpnonce field AND the _wp_http_referer field.
        wp_nonce_field( 'acme_save_settings' );

        // Longhand — exactly the same two fields, written out:
        // printf(
        //     '<input type="hidden" name="_wpnonce" value="%s">',
        //     esc_attr( wp_create_nonce( 'acme_save_settings' ) )
        // );
        // wp_referer_field();
        ?>

        <input type="text" name="acme_api_key" value="">
        <?php submit_button(); ?>
    </form>
    <?php
}

/**
 * The handler.
 */
add_action( 'admin_post_acme_save_settings', 'acme_save_settings' );

function acme_save_settings() {
    // Intent. Dies with the "Are you sure?" screen if the nonce is missing or stale.
    check_admin_referer( 'acme_save_settings' );

    // Authorization. The nonce said "you meant this", not "you are allowed".
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( esc_html__( 'Insufficient permissions.', 'acme' ), '', array( 'response' => 403 ) );
    }

    update_option(
        'acme_api_key',
        sanitize_text_field( wp_unslash( $_POST['acme_api_key'] ?? '' ) )
    );

    wp_safe_redirect( wp_get_referer() ?: admin_url() );
    exit;
}

When to use it

  • Adding CSRF protection to a plugin settings form posted to admin-post.php
  • Securing an admin-ajax.php action called from a block editor sidebar panel
  • Building delete or approve links in an admin list table that cannot be triggered from another site
  • Diagnosing why a form fails verification for logged-out users or behind a full-page cache

More examples

Nonce for an AJAX endpoint

Browser JavaScript cannot generate a nonce, so you create it in PHP and hand it to the script. check_ajax_referer() with $stop left at its default true prints -1 and exits on failure, which is what the admin-ajax protocol expects; pass false as the third argument when you want to return a proper JSON error instead. Use this for any admin-ajax.php action.

Example · php
<?php
add_action( 'admin_enqueue_scripts', function () {
    wp_enqueue_script( 'acme-admin', plugins_url( 'js/admin.js', __FILE__ ), array(), '1.0.0', true );

    // The nonce travels to JS as data; the client sends it back as _ajax_nonce.
    wp_localize_script( 'acme-admin', 'acmeAdmin', array(
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'acme_refresh_stats' ),
    ) );
} );

add_action( 'wp_ajax_acme_refresh_stats', function () {
    // $stop = false so we can send JSON rather than admin-ajax's bare -1.
    if ( ! check_ajax_referer( 'acme_refresh_stats', false, false ) ) {
        wp_send_json_error( array( 'message' => 'Expired. Reload the page.' ), 403 );
    }

    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( array( 'message' => 'Not allowed.' ), 403 );
    }

    wp_send_json_success( acme_collect_stats() );
} );

// admin.js sends:
//   body: new URLSearchParams( {
//       action: 'acme_refresh_stats',
//       _ajax_nonce: acmeAdmin.nonce
//   } )

A nonced link, verified by hand

wp_nonce_url() appends the token as a query argument, so the check reads it from $_GET. Verifying manually instead of using check_admin_referer() lets you show a friendly "link expired" page rather than the blunt die() screen — useful for links that sit in emails or bookmarks and are routinely stale. Note the per-object action string: a nonce for report 7 will not delete report 8.

Example · php
<?php
// Render the link. The nonce action names the exact object.
$url = wp_nonce_url(
    admin_url( 'admin.php?page=acme-reports&do=delete&id=' . $report->id ),
    'acme_delete_report_' . $report->id
);
printf( '<a href="%s">%s</a>', esc_url( $url ), esc_html__( 'Delete', 'acme' ) );

// Handle the click.
if ( isset( $_GET['do'], $_GET['id'] ) && 'delete' === $_GET['do'] ) {
    $id    = absint( $_GET['id'] );
    $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_key( $_GET['_wpnonce'] ) : '';

    // Returns 1, 2 or false — never true. Compare against false, or negate.
    if ( ! wp_verify_nonce( $nonce, 'acme_delete_report_' . $id ) ) {
        wp_admin_notice( __( 'That link has expired. Try again.', 'acme' ), array( 'type' => 'error' ) );
        return;
    }

    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    acme_delete_report( $id );
}

Shortening the nonce lifespan

The default lifespan is DAY_IN_SECONDS, and wp_verify_nonce() returns 1 in the first half of it and 2 in the second. Shortening it narrows the replay window for sensitive actions; the $action argument, available to this filter since WordPress 6.1, lets you do that for one action without shortening every nonce on the site. Keep in mind that anything under a couple of hours will start expiring on users who leave a tab open.

Example · php
<?php
add_filter( 'nonce_life', function ( $lifespan, $action ) {
    // Only tighten the window for the destructive action.
    if ( is_string( $action ) && str_starts_with( $action, 'acme_delete_report_' ) ) {
        return 2 * HOUR_IN_SECONDS;
    }
    return $lifespan; // DAY_IN_SECONDS by default
}, 10, 2 );

// Reading the return value's tier, if you want to re-prompt on older tokens.
$tier = wp_verify_nonce( $nonce, 'acme_delete_report_' . $id );
if ( false === $tier ) {
    wp_die( esc_html__( 'Link expired.', 'acme' ) );
}
if ( 2 === $tier ) {
    // Issued in the second half of its life — ask for confirmation again.
    acme_render_confirmation_screen( $id );
    exit;
}

Discussion

  • Be the first to comment on this lesson.