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

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.

Lab 35 minutes

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.