Skip to main content

Nonces & Capabilities

Introduction​

WordPress has no CSRF token and no session layer. Two mechanisms cover the same ground:

ConcernMechanism
Is this request forged?Nonces — a short-lived token tied to an action and user
Is this user allowed?Capabilities — current_user_can()

A nonce answers did this request come from our UI, a capability answers may this user do it. An endpoint that changes data needs both.

Nonces​

The nonce is created when the admin page renders and handed to the frontend:

// backend/app/Views/Head.php
'nonce' => wp_create_nonce(Config::withPrefix('nonce')),
'restNonce' => wp_create_nonce('wp_rest'),

The frontend sends it back as _ajax_nonce, and the middleware verifies it:

final class NonceCheckerMiddleware
{
public function handle(Request $request)
{
if (!$request->has('_ajax_nonce')
|| !wp_verify_nonce(sanitize_key($request->_ajax_nonce), Config::withPrefix('nonce'))
) {
return Response::error('Invalid nonce token')->httpStatus(411);
}

return true;
}
}

Attach it by name to every route that writes:

Route::group(
function (): void {
Route::post('tags/save', [TagController::class, 'store']);
}
)->middleware('nonce', 'isAdmin');

REST routes use the standard wp_rest nonce, which WordPress checks itself when the client sends it as the X-WP-Nonce header.

note

Nonces expire, by default after 24 hours. A long-open admin tab can produce a 411 on a request that looked fine yesterday, so the frontend should handle that response by refreshing rather than by retrying.

Capabilities​

Capabilities::check() wraps current_user_can():

use YourPlugin\Deps\BitApps\WPKit\Utils\Capabilities;

if (!Capabilities::check('manage_options')) {
return Response::error('Access Denied')->httpStatus(411);
}

Capabilities::filter() checks a capability, and also lets a filter of the same name grant access — useful when a site wants to delegate a plugin screen to a non-admin role:

Capabilities::filter('bitapps_pi_manage_flows', 'manage_options');

The admin gate ships as middleware, so most routes get it by name:

final class AdminCheckerMiddleware
{
public function handle()
{
if (!Capabilities::check('manage_options')) {
return Response::error('Access Denied: Only administrators are allowed to make this request')
->httpStatus(411);
}

return true;
}
}

Unauthenticated routes​

noAuth() registers the wp_ajax_nopriv_* action too, so logged-out visitors reach the route. That is required for webhook receivers and OAuth callbacks, and it removes both protections above:

Route::noAuth()->group(
function (): void {
Route::match(['post', 'get'], 'webhook/callback/{trigger_id}', [WebhookDispatchController::class, 'handleWebhook']);
}
);
warning

A noAuth() route is public internet. It must authenticate the caller by itself — a signed payload, a secret in the path, an allowlisted IP — and validate every input. Nonce and capability middleware cannot help, because there is no logged-in user to bind them to.

Checklist​

For any route that writes data:

  • nonce middleware, or an equivalent check for public routes
  • isAdmin, or a narrower capability check
  • Validation with sanitize: rules on every field
  • Queries through the query builder, which binds values and quotes identifiers