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.
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.