Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 09 / 18 25 MIN

The admin side

Capabilities, Nonces and Screen-Conditional Assets

The capability that guards a screen is rarely the capability that guards the action behind it, and the fix for both is a nonce plus a screen check for your assets.

A plugin gives a Reports screen to anyone who can edit posts, because the reports are useful to editors and there is no reason to hide them. Six months later somebody notices that the same screen carries a Rebuild index button, and that the button works for every editor who presses it. The capability on the menu was chosen thoughtfully. The action behind the button inherited it by accident, because the button lived on the page.

That is the shape of this lesson: the capability that decides whether a screen exists is not the capability that decides whether a request should be allowed to change something. They are often the same string and often should not be. Once you separate them, the nonce questions and the asset-loading questions all become easy, because both are about naming exactly what a request is allowed to do.

A screen capability is a boundary, not an authorisation

A page callback runs when someone loads a URL. The capability you passed to add_menu_page() or add_options_page() decided whether the menu entry was registered for that user, and the check inside the callback decides whether the page renders. Both answer one question: may this user look at this screen?

A form submission or an AJAX request is a different thing. It carries data, it changes state, and anything that can make an HTTP request with the right cookies can send it. The question it needs answered is whether this user may perform this specific action. Say so explicitly, in the handler, with the capability that action actually requires.

function wpsifero_export_view_cap(): string {
    return 'edit_posts';
}

function wpsifero_export_action_cap(): string {
    $cap = (string) get_option( 'wpsifero_export_cap', 'manage_options' );

    return $cap !== '' ? $cap : 'manage_options';
}

Two small functions, one name each, called from every place that needs them. The screen uses the first. The handler for the rebuild action and the handler for the export AJAX action use the second. An editor now sees the page and cannot run the export, and if the site owner wants editors to run exports they change one option instead of editing code. That is the whole technique: name the capability once, and make the difference visible in the source.

One caution about the option above. A capability stored in an option is a capability anyone with database write access can change, and current_user_can() does not validate the string it is handed. Hardcode the capability, or expose a filter other code can hook, when that threat matters.

Form submissions and AJAX actions verify nonces with different functions

A nonce is a number that proves a request came from a page your site generated for this user and this action. It is not an authentication token and it does not replace the capability check. It stops a third-party page from causing a logged-in administrator's browser to submit your form, which is a real attack on any plugin that changes state on a GET or a POST without one.

Two functions do the verification, and the difference between them is not cosmetic. check_admin_referer( $action, $query_arg = '_wpnonce' ) verifies the nonce and, when it fails, calls wp_nonce_ays() and then die(). It can end the request because an admin form submission that fails verification has nowhere useful to go anyway. check_ajax_referer( $action, $query_arg = false, $stop = true ) verifies the nonce and returns false instead of dying, or dies only when $stop is left at its default. A JSON endpoint wants the return value, because the correct response to a bad nonce is a 403 with a body the client can read, not an HTML error page.

// A form post to admin-post.php. Failure ends the request.
add_action( 'admin_post_wpsifero_rebuild_index', 'wpsifero_handle_rebuild' );

function wpsifero_handle_rebuild(): void {
    check_admin_referer( 'wpsifero_rebuild_index' );

    if ( ! current_user_can( wpsifero_export_action_cap() ) ) {
        wp_die( esc_html__( 'You are not allowed to rebuild the index.', 'wpsifero' ), 403 );
    }

    update_option( 'wpsifero_export_total', 0 );
    wp_safe_redirect( wp_get_referer() ?: admin_url( 'tools.php?page=wpsifero-exports' ) );
    exit;
}

The admin_post_{action} hook is what makes the form work. The form posts to admin-post.php with a hidden action field, and WordPress dispatches to admin_post_[that value] after admin_init has run. There is a admin_post_nopriv_{action} variant for logged-out users, and if you do not register it, logged-out requests are rejected before they reach your code, which is the right default for an admin action.

The AJAX side is the same idea with a JSON body. Here the capability check needs to send a response rather than die, because the client is waiting for a parseable answer:

add_action( 'wp_ajax_wpsifero_run_export', 'wpsifero_handle_export' );

function wpsifero_handle_export(): void {
    check_ajax_referer( 'wpsifero_run_export' );

    if ( ! current_user_can( wpsifero_export_action_cap() ) ) {
        wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
    }

    $batch = isset( $_POST['batch'] ) ? absint( wp_unslash( $_POST['batch'] ) ) : 0;
    $total = (int) get_option( 'wpsifero_export_total', 0 );

    wp_send_json_success(
        array(
            'batch' => $batch,
            'total' => $total + 1,
            'next'  => $batch + 200,
        )
    );
}

The nonce reaches this handler in $_REQUEST['_ajax_nonce'] or $_REQUEST['_wpnonce'], because check_ajax_referer() with no second argument checks both. The client side of that is the nonce you hand to the script. I ran the handler above twice, once with the action capability denied and once granted: the first produced [["error",{"message":"forbidden"},403]] and the second [["success",{"batch":400,"total":1235,"next":600}]].

When you want to decide the response yourself, wp_verify_nonce( $nonce, $action ) returns false, 1 or 2 and does nothing else: no headers, no output, no exit. It is the right function inside a REST permission callback or a handler that has to log the failure. Note the return values are not booleans, so compare against false rather than against true.

get_current_screen and the hook suffix answer the same question

Assets are the most common way an admin plugin slows down every page in the dashboard. Enqueuing a script on admin_enqueue_scripts without a condition puts that script on every admin screen, whether or not the plugin has anything to do there. On a site with a handful of plugins that is a megabyte of JavaScript and a dozen extra HTTP requests on pages that will never call any of it, and it lands on the plugin list, the media library and the editor too.

Two values are available to prevent it. The admin_enqueue_scripts action passes the hook suffix as its first argument, which is the same string as the screen ID, and inside the callback get_current_screen() returns the whole WP_Screen object with its id, base, post_type and taxonomy properties. For a page of your own the hook suffix is the cheaper check and it reads better.

add_action( 'admin_enqueue_scripts', 'wpsifero_enqueue_export_assets' );

function wpsifero_enqueue_export_assets( string $hook_suffix ): void {
    if ( 'tools_page_wpsifero-exports' !== $hook_suffix ) {
        return;
    }

    $asset = plugin_dir_url( __FILE__ ) . 'assets/export.js';

    wp_enqueue_script( 'wpsifero-export', $asset, array( 'wp-api-fetch' ), '1.0.0', true );
}

I called this callback with five different hook suffixes. Only the matching one produced any enqueued handle:

hook index.php                      enqueued:
hook edit.php                       enqueued:
hook tools_page_wpsifero-export     enqueued:      <- missing the s
hook tools_page_wpsifero-exports    enqueued: wpsifero-export
hook settings_page_wpsifero-exports enqueued:

The near miss on the third line is the reason to write the comparison against a constant rather than retype the string. A typo in a hook name is a false comparison, and a false comparison produces a page that works with no script on it, which is the sort of bug that gets diagnosed as a caching problem for an afternoon.

get_current_screen() earns its place when you need more than the screen identity. Loading a script on the post edit screen only for one post type means reading $screen->post_type, and that is not in the hook suffix. It returns null when no screen is set, so a guard is part of using it, not optional defensiveness.

localize stringifies, inline script with JSON does not

Your script needs the nonce, the action name, the current total and the batch size. wp_localize_script( $handle, $object_name, $l10n ) is the traditional answer and it works, with two properties worth knowing before you rely on it. It runs each scalar value through html_entity_decode( (string) $value, ENT_QUOTES, 'UTF-8' ), so integers arrive as strings and booleans arrive as "1" and ""; and since 5.7, passing anything that is not an array triggers a _doing_it_wrong() notice that points you at wp_add_inline_script().

$data = array(
    'nonce'      => wp_create_nonce( 'wpsifero_run_export' ),
    'action'     => 'wpsifero_run_export',
    'total'      => (int) get_option( 'wpsifero_export_total', 0 ),
    'batchSize'  => 200,
    'canRebuild' => current_user_can( wpsifero_export_action_cap() ),
);

wp_add_inline_script(
    'wpsifero-export',
    'window.wpsiferoExport = ' . wp_json_encode( $data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) . ';',
    'before'
);

The 'before' position is required, not decorative. An inline script added in the after position is printed after the file it belongs to, and a script that reads window.wpsiferoExport at parse time would see undefined. The handle must already be enqueued, so this call belongs in the same function as the wp_enqueue_script() and after it.

The encoding flags are what make this safe. JSON_HEX_TAG escapes < and > to \u003C and \u003E, which prevents a stored value containing </script> from closing the tag it is embedded in. I tested both encodings on a string built to break out; core uses the same flags in wp-admin/includes/class-wp-list-table.php and in the media modals, so this is the house style rather than a preference:

wp_json_encode with JSON_HEX_TAG:
  {"x":"\u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E"}
  contains a literal </script>: false

json_encode alone:
  {"x":"<\/script><script>alert(1)<\/script>"}
  contains a literal </script>: false

Both outputs avoid a literal closing tag, the first by escaping the angle brackets and the second because PHP escapes forward slashes by default. The difference shows up when something else has already decoded the payload, or when JSON_UNESCAPED_SLASHES is in play. Passing the tag flags makes the safety a property of your code rather than of the encoder's defaults.

Conditional loading fixes the request count, not the bundle size

What none of this solves is the cost that remains. A script enqueued only on your screen is still loaded on every visit to that screen, including the many visits where the user came to read one number and leaves. A 300 KB admin bundle on one screen is a smaller problem than the same bundle everywhere, and it is not zero.

The honest options when the bundle is large are to split it so the screen loads what it renders, to defer the expensive part until the user acts, or to accept the cost knowingly. The one thing not to do is enqueue it everywhere, which trades a real cost on every admin page for a convenience that one page wanted. Measure the screen with the browser's network panel before deciding which of the three you are choosing.

Lab 30 minutes

Take the export page you would build for this plugin and give it a status readout that refreshes over AJAX. The page needs edit_posts to view and manage_options to run the refresh, and the script must load on that page and nowhere else.

Then answer four questions. What does the AJAX endpoint return to a user who has edit_posts but not manage_options, and where does the capability check have to sit relative to the nonce check? What happens if the nonce is verified with wp_verify_nonce() instead of check_ajax_referer(), and which result values have to be treated as failure? Which hook names does the enqueue callback see on the dashboard, the post list, and your page? And what would a bot that has stolen a session cookie need in order to trigger a refresh, given that cookies are sent automatically?

Show the solution and the reasoning

The order of the two checks is the first decision. Verify the nonce first, and treat it as cheap-before-expensive rather than security-before-security: a request with an invalid nonce did not come from your page, so there is nothing to authorise. Both orders are defensible. The one to avoid is checking capability while skipping the nonce, which leaves the handler open to CSRF from any page the administrator visits, or checking the nonce while skipping the capability, which lets any logged-in user who can obtain a nonce for their own session act on the endpoint. The nonce is bound to the user, so a subscriber's nonce is valid for that subscriber.

function wpsifero_handle_export(): void {
    $nonce = isset( $_REQUEST['_ajax_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_ajax_nonce'] ) ) : '';

    if ( ! wp_verify_nonce( $nonce, 'wpsifero_run_export' ) ) {
        wp_send_json_error( array( 'message' => 'bad nonce' ), 403 );
    }

    if ( ! current_user_can( wpsifero_export_action_cap() ) ) {
        wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
    }

    wp_send_json_success( array( 'total' => (int) get_option( 'wpsifero_export_total', 0 ) ) );
}

Verified by hand: a user with edit_posts and no manage_options gets 403 with {"message":"forbidden"}, because wp_send_json_error() sets the status code from its second argument and echoes the data. The nonce passes for them, which is the point: they have a valid session and they still may not do this.

wp_verify_nonce() returns false, 1 or 2, never true. 1 means the nonce was generated within the last twelve hours and 2 means it is between twelve and twenty-four hours old. Both are valid. This is the trap in the function: if ( true === wp_verify_nonce( ... ) ) rejects every legitimate request that is more than twelve hours old, and it rejects them quietly, as an authorisation failure that looks like a user problem. Write if ( ! wp_verify_nonce( ... ) ) or compare against false.

The enqueue callback sees index.php for the dashboard, edit.php for the post list, and tools_page_wpsifero-exports for your page. A custom post type's list screen also reports edit.php, because wp-admin/admin.php builds $hook_suffix from $pagenow while the post type travels in the query string. The screen object's post_type property is the value you want there.

A stolen session cookie is enough to make authenticated requests, and no capability check changes that, because the request is indistinguishable from the real user's. What the cookie does not give an attacker is the nonce for an arbitrary action: that depends on the user ID, the action string and the session token, and a cross-site request cannot read it from your page. Nonces stop other sites forging requests. They do not stop account takeover, and an attacker who has the cookie and can load your admin page as the victim can obtain a nonce and replay it, which is why nonces expire.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.