The admin side
AJAX Endpoints, Nonces and Screen Assets
How the plugin registers its two AJAX endpoints, why one nonce action across a logged-in and a logged-out surface is a bug, and how admin assets stay scoped.
Two endpoints, two nonce actions, nine admin screens, and one asset function that has to decide which of them are actually on screen. That is the whole client side of Sifero Courier Timeline, and it is registered in six lines at the bottom of the bootstrap file.
// sifero-courier-timeline-for-woocommerce.php, lines 286-288
add_action( 'wp_ajax_sfct_admin_action', 'sfct_ajax_admin_action' );
add_action( 'wp_ajax_sfct_public_lookup', 'sfct_ajax_public_lookup' );
add_action( 'wp_ajax_nopriv_sfct_public_lookup', 'sfct_ajax_public_lookup' );
Read those three lines closely, because the difference between them is the difference between an endpoint a logged-out visitor can reach and one they cannot.
wp_ajax_ and wp_ajax_nopriv_ are two registrations, not one
The compound action name is the whole API: wp_ajax_{$action} fires for a
logged-in request, wp_ajax_nopriv_{$action} for a logged-out one. The
Plugin Handbook's AJAX
page registers the same pair from a script and shows the
wp_localize_script call that hands the url and the nonce to the browser. The
visitor's login state decides which hook WordPress looks at, so an endpoint that should
work for everyone needs both. Register only the first and anonymous requests never reach
your function at all. wp-admin/admin-ajax.php checks before it fires
anything:
// wp-admin/admin-ajax.php, WordPress 7.0.7, shortened
if ( is_user_logged_in() ) {
if ( ! has_action( "wp_ajax_{$action}" ) ) {
wp_die( '0', 400 );
}
do_action( "wp_ajax_{$action}" );
} else {
if ( ! has_action( "wp_ajax_nopriv_{$action}" ) ) {
wp_die( '0', 400 );
}
do_action( "wp_ajax_nopriv_{$action}" );
}
I POSTed action=sfct_admin_action to a local install without a session
cookie and got the body 0 and HTTP 400. Same request with a valid session
cookie reached the handler and returned JSON. The logged-out request was never refused by
the plugin, because the plugin was never called.
The public endpoint has the opposite shape and no capability check at all. It reads a
tracking id, asks sfct_get_tracking_payload() for it, and answers with
either a JSON error or the payload. That is correct for an unauthenticated lookup, and it
is why the nonce on that surface cannot be doing the work people usually assume it does.
A nonce on a public endpoint proves the request came from a page you served. It does not
prove the caller is allowed to see the data. That proof has to come from the data itself,
which here means the tracking id being unguessable and
sfct_get_tracking_payload() checking the delivery state before it returns
status details.
check_ajax_referer takes one action and three places to look
// includes/ajax.php, line 13
check_ajax_referer( 'sfct_admin_nonce', 'nonce' );
The second argument is a request key, not an action name. The action is the first
argument. WordPress looks for the nonce in that key first, then falls back to
_ajax_nonce, then to _wpnonce, reading all three from
$_REQUEST:
// wp-includes/pluggable.php, WordPress 7.0.7, shortened
$nonce = '';
if ( $query_arg && isset( $_REQUEST[ $query_arg ] ) ) {
$nonce = $_REQUEST[ $query_arg ];
} elseif ( isset( $_REQUEST['_ajax_nonce'] ) ) {
$nonce = $_REQUEST['_ajax_nonce'];
} elseif ( isset( $_REQUEST['_wpnonce'] ) ) {
$nonce = $_REQUEST['_wpnonce'];
}
$result = wp_verify_nonce( $nonce, $action );
do_action( 'check_ajax_referer', $action, $result );
if ( $stop && false === $result ) {
if ( wp_doing_ajax() ) {
wp_die( -1, 403 );
} else {
die( '-1' );
}
}
Passing 'nonce' here means the plugin's own key is tried first, which
matters because a third-party form on the same screen can put an unrelated
_wpnonce in the request. Omitting the second argument is not an error, but it
silently widens what the function will accept.
The failure path is worth memorising. A request with no nonce, a truncated nonce or an
expired one does not return false and continue. It dies with the body -1 and
HTTP 403, before a single line of your subaction switch runs. I confirmed that in the
lab: the public endpoint with no nonce returned -1 at
http=403, and with a garbage nonce it returned -1 as well.
The return value carries more information than a boolean. wp_verify_nonce()
returns 1 for a nonce created in the current 12-hour window,
2 for one from the previous window, and false for anything else.
Treating any truthy value as valid, which is what almost every plugin does, accepts
nonces up to 24 hours old. That is the intended lifetime, not a bug, and it is the reason
a nonce cannot be your only defence on a destructive action.
One nonce action cannot serve two audiences
The plugin scopes its nonces by surface. includes/assets.php creates
sfct_admin_nonce for the admin script and sfct_public_nonce for
the frontend script, and each endpoint verifies the matching one. That separation is
load-bearing, and the reason is arithmetic, not policy. A nonce is a hash of four
things:
// wp-includes/pluggable.php, WordPress 7.0.7, shortened
$i = wp_nonce_tick( $action );
$token = wp_get_session_token();
// created and verified with:
substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 )
$uid is the current user id and $token is their session
token. A logged-in administrator's nonce therefore contains their user id and session
token, and a logged-out visitor's contains 0 and an empty token. The two can
never collide, and neither validates for the other audience. I checked both directions
against the real handlers:
logged-in admin nonce, verified as user 0 => false
public (user 0) nonce, verified as user 1 => false
public nonce posted to sfct_admin_action => -1
admin nonce posted to sfct_public_lookup => -1
Now suppose a plugin used sfct_nonce everywhere, which is tempting once you
notice that both surfaces call the same helper. The logged-out value is built from a user
id of 0, an empty session token, the tick
ceil( time() / ( 12 * HOUR_IN_SECONDS ) ) and the salt in
wp-config.php. Only the salt is a secret, and a nopriv nonce for
a shared action is then a value that a weak or duplicated salt makes guessable. Share one
action name and the admin endpoint's nonce check inherits that weakness.
The two surfaces also differ in lifetime. The public nonce rides along in a page served to anonymous visitors. The admin nonce belongs to one logged-in session and must never be carried into a page served to someone else. One action name cannot express that distinction, so the code stops knowing which surface a given script was handed.
Nonce first, capability second, and the order matters
The admin handler opens with both checks in the right sequence:
// includes/ajax.php, lines 12-17
function sfct_ajax_admin_action() {
check_ajax_referer( 'sfct_admin_nonce', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'You do not have sufficient permissions.', 'sifero-courier-timeline-for-woocommerce' ) ), 403 );
}
$subaction = isset( $_POST['subaction'] ) ? sanitize_key( wp_unslash( $_POST['subaction'] ) ) : '';
Run the nonce check first and the capability check second, and do not treat either as a
substitute for the other. The Plugin
Handbook's security pages separate the two under "validated" and "authorised" for
exactly this reason: they answer different questions. The nonce answers "did this
request come from a page we served to this user, within the last day". The capability
check answers "is this user allowed to do it". A subscriber who scrapes a valid nonce from
a page they can load still fails current_user_can(). An administrator whose
nonce expired 25 hours ago is refused before the capability check runs, which is the
noisier outcome and the easier one to debug.
The two responses are deliberately different shapes. wp_send_json_error( $value,
403 ) prints {"success":false,"data":{...}} with HTTP 403, so the
admin script can show the message inline. A failed nonce gives the browser
-1 and nothing to display, because there is no user-facing thing to say about
a request that may not have come from your page at all. The JavaScript handles the two
cases separately, and that is the convention worth copying.
One subaction adds a third layer. save_order_tracking verifies a
per-order nonce whose action string includes the order id,
sfct_save_order_from_orders_page_{$order_id}, and then asks for
edit_shop_order on that same order. The id in the action name is what binds
the token to one order: a nonce minted for order 1024 does not validate for order 1025,
so a captured request cannot be replayed against a different order. That is a good
pattern, and it does the job the shared admin nonce cannot do on its own.
The switch that follows sanitises on the way in: subactions through
sanitize_key(), ids through absint(), urls through
esc_url_raw() with wp_http_validate_url() behind them. The one
subaction that renders HTML builds it with
sfct_render_orders_admin_card() rather than handing the script raw data to
template.
Scope admin assets with the screen, not with is_admin()
sfct_admin_assets() runs on admin_enqueue_scripts, which core
fires from wp-admin/admin-header.php with the hook suffix as its argument.
Two useful facts about that moment. wp-admin/admin.php calls
set_current_screen() before the header is required, so
get_current_screen() returns a populated WP_Screen here, and
its id is the same string as $hook_suffix. That is why the
plugin can test either one; testing both is belt and braces, not a subtle trick.
// includes/assets.php, lines 107-125, shortened
function sfct_admin_assets( $hook ) {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
$screen_id = $screen ? $screen->id : '';
$is_sfct_page = false !== strpos( (string) $hook, 'sfct-' ) || false !== strpos( $screen_id, 'sfct-' ) || false !== strpos( (string) $hook, SFCT_ADMIN_MENU_SLUG ) || false !== strpos( $screen_id, SFCT_ADMIN_MENU_SLUG );
$is_order_page = in_array( $screen_id, array( 'shop_order', 'woocommerce_page_wc-orders' ), true );
if ( ! $is_sfct_page && ! $is_order_page ) {
return;
}
wp_enqueue_style( 'sfct-admin', SFCT_URL . 'assets/css/admin-style.css', array(), SFCT_VERSION );
if ( false !== strpos( (string) $hook, 'sfct-templates' ) || false !== strpos( $screen_id, 'sfct-templates' ) ) {
wp_enqueue_style( 'sfct-frontend-preview', SFCT_URL . 'assets/css/frontend-style.css', array(), SFCT_VERSION );
}
wp_enqueue_script( 'sfct-admin', SFCT_URL . 'assets/js/admin-app.js', array(), SFCT_VERSION, true );
if ( false !== strpos( (string) $hook, 'sfct-settings' ) || false !== strpos( $screen_id, 'sfct-settings' ) ) {
wp_enqueue_script( 'sfct-admin-settings', SFCT_URL . 'assets/js/admin-settings.js', array(), SFCT_VERSION, true );
}
wp_enqueue_media();
The early return is the important line. Without it the plugin loads roughly a
megabyte of JavaScript on every screen in wp-admin, because the next call is
wp_enqueue_media(), and that is not a small function. It enqueues
media-editor, media-audiovideo, mce-view,
image-edit, the media-views and imgareaselect
stylesheets, calls wp_plupload_default_settings(), and hooks
wp_print_media_templates to the footer. It is guarded against running twice
per request by a did_action( 'wp_enqueue_media' ) check, not against running
where it is not needed.
I logged the enqueues from a real admin request per screen. On
admin.php?page=sfct-orders the stylesheet and script queues gained
sfct-admin, plus media-views and media-editor. On
edit.php nothing was added. That is the scoping working.
It is also broader than it needs to be. The media library exists for one control, the
courier logo upload on the Couriers screen, and the Orders and Statuses screens receive it
too because $is_sfct_page matches the substring sfct- on all
nine screens. The substring also matches the id of any other plugin's screen containing
those four characters. An exact screen id comparison is tighter on both counts:
if ( 'sifero-courier-timeline_page_sfct-couriers' === $screen_id ) {
wp_enqueue_media();
}
Localised data and the version argument
wp_localize_script( 'sfct-admin', 'SFCT_ADMIN', array( ... ) ) attaches a
JSON-encoded object to a script that is already registered. On the lab install the
attached payload measured 8,563 bytes. It contains the ajax url, the nonce, the status
list, the icon bank and around 60 translated strings, which the script reads as
window.SFCT_ADMIN. The frontend script gets the same treatment under the
name SFCT_FRONTEND with admin_url( 'admin-ajax.php' ) and a
sfct_public_nonce generated for whoever is looking at the page.
Serving the ajax url this way rather than hard-coding
/wp-admin/admin-ajax.php is not optional. A store can run WordPress in a
subdirectory, or move it later, and a
relative path in a cached script file will keep pointing at the old location until the
browser cache expires.
How does a released file reach a browser that already has the old one? The last argument
to wp_enqueue_script() and wp_register_script() answers that. It
becomes a query string appended to the URL, which is what makes the browser fetch the
file again after a release. Every SFCT asset passes SFCT_VERSION:
/wp-content/plugins/sifero-courier-timeline-for-woocommerce/assets/js/admin-settings.js?ver=1.2.0
/wp-content/plugins/sifero-courier-timeline-for-woocommerce/assets/css/admin-style.css?ver=1.2.0
/wp-content/plugins/sifero-courier-timeline-for-woocommerce/assets/css/frontend-style.css?ver=1.2.0
Those URLs came back from the rendered settings and templates screens in the lab. The
failure mode this prevents is specific: you fix admin-app.js, the plugin
version stays at 1.2.0, and every store that already loaded the file keeps running the old
one until its cache expires. Bump the version when you ship.
Add a third endpoint to a copy of the plugin, sfct_delete_tracking,
that removes one tracking record. It has to be reachable by a logged-in user on the
Orders screen. Get the registration, the nonce and the capability check right, and
say out loud which hook you registered and why the other two would be wrong.
Then break it twice on purpose. First, register it only on
wp_ajax_nopriv_ and explain the response you get as an administrator.
Second, use sfct_public_nonce as its action and explain what is
actually wrong with that, given that the event handler in the admin script is the
only thing that will ever send it.
Finally, make the Couriers screen the only screen that enqueues the media library and confirm the change on two screens by dumping the style and script queues.
Show the solution and the reasoning
add_action( 'wp_ajax_sfct_delete_tracking', 'sfct_ajax_delete_tracking' );
function sfct_ajax_delete_tracking() {
check_ajax_referer( 'sfct_admin_nonce', 'nonce' );
$tracking_id = isset( $_POST['tracking_id'] ) ? sanitize_text_field( wp_unslash( $_POST['tracking_id'] ) ) : '';
if ( '' === $tracking_id ) {
wp_send_json_error( array( 'message' => __( 'Missing tracking id.', 'sifero-courier-timeline-for-woocommerce' ) ), 400 );
}
$order = sfct_find_order_by_tracking_id( $tracking_id );
if ( ! $order ) {
wp_send_json_error( array( 'message' => __( 'Tracking record not found.', 'sifero-courier-timeline-for-woocommerce' ) ), 404 );
}
if ( ! current_user_can( 'edit_shop_order', $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'You do not have permission to edit this order.', 'sifero-courier-timeline-for-woocommerce' ) ), 403 );
}
global $wpdb;
$deleted = $wpdb->delete( sfct_logs_table(), array( 'tracking_id' => $tracking_id ), array( '%s' ) );
if ( false === $deleted ) {
wp_send_json_error( array( 'message' => __( 'The record could not be deleted.', 'sifero-courier-timeline-for-woocommerce' ) ), 500 );
}
wp_send_json_success( array( 'deleted' => (int) $deleted, 'tracking_id' => $tracking_id ) );
}
The registration is wp_ajax_sfct_delete_tracking only. The action is
destructive and it has no meaning for a logged-out visitor, so there is no
nopriv twin to add. Registering one anyway would be worse than useless: it
would hand the endpoint to every anonymous request and leave the nonce as the only
thing standing between a stranger and a delete. The capability check is also
deliberately object level. sfct_find_order_by_tracking_id() returns the
WC_Order the tracking id belongs to, so the handler can ask whether this
user may edit that order and fall back to manage_woocommerce only for the
roles that hold the store-wide capability. Resolve the order before the check, or there
is no order id to test against.
Registering it on wp_ajax_nopriv_ gives an administrator a body of
0 and HTTP 400, which is the diagnosis for "my endpoint returns 400 while I
am logged in". admin-ajax.php looks at
wp_ajax_sfct_delete_tracking for a logged-in request, finds nothing
registered there, and dies before your code is reached. The mirror image, registering
only wp_ajax_ for something a visitor needs, produces the same
0 with no cookie in the request.
Switching the action to sfct_public_nonce fails for the reason the
earlier section worked through, and the fact that only your own script will ever send it
does not help. The nonce on the admin page is created by
wp_create_nonce( 'sfct_public_nonce' ) while an administrator is logged in,
so it is bound to that administrator's user id and session token, and the check passes
for them. You have not gained a hole on that request. What you have done is made the
public surface the owner of an action name, so the next developer who adds a public
endpoint reaches for the same nonce, and the delete endpoint's only protection becomes a
value minted for anonymous visitors and, in a badly configured install, computable
without an account. Two audiences, two action names. It costs one string.
The asset change replaces the broad test with the screen that owns the upload control:
if ( 'sifero-courier-timeline_page_sfct-couriers' === $screen_id ) {
wp_enqueue_media();
}
Verify it the way the lab for this lesson asked. Hook
admin_enqueue_scripts at priority 99, log
wp_styles()->queue and wp_scripts()->queue to a file
keyed by get_current_screen()->id, and request the Couriers and
Statuses screens with a logged-in cookie. Couriers should show
media-views and media-editor; Statuses should not. If the
probe writes one file per request, use FILE_APPEND, or the second request
overwrites the first and both screens look identical.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.