Blog · Guides

Nonces and Capability Checks: Writing WordPress Code That Cannot Be CSRF’d

Most WordPress security incidents in custom code do not come from exotic exploits. They come from a form handler, an AJAX action, or a REST endpoint that trusts a request just because it arrived. If you write or maintain custom plugins and themes, there is a two-line habit that closes the vast majority of these gaps: verify a nonce, then check a capability. Do both, every time a request changes data, and you eliminate an entire category of bugs.

What CSRF looks like in a WordPress request

Cross-site request forgery (CSRF) happens when a malicious page tricks a logged-in user’s browser into sending a request to your site that the user never intended. The browser automatically attaches the user’s session cookies, so from the server’s point of view the request looks legitimate. If your handler only checks that a user is logged in, an attacker can forge a link or auto-submitting form on another site and get your admin’s browser to delete content, change settings, or create a new administrator account, all without the admin clicking anything on your site.

Privilege escalation is the related problem: even a logged-in, authenticated user should only be able to do what their role allows. A subscriber submitting a form should never be able to trigger code that only an editor or administrator is meant to run. Authentication (who are you) and authorization (what are you allowed to do) are two separate questions, and WordPress gives you a specific tool for each.

The nonce half of the fix

A WordPress nonce is a short-lived, per-user, per-action token. It does not prove someone is authorized. It proves the request actually originated from a page your site generated, for a specific user, within a specific time window. That is exactly what stops CSRF: an attacker’s forged form on another domain cannot know the correct nonce value for your logged-in user.

In a form, generate the nonce with wp_nonce_field():

<form method="post" action="">
  <?php wp_nonce_field( 'sb_save_widget', 'sb_widget_nonce' ); ?>
  <input type="text" name="widget_title" />
  <button type="submit">Save</button>
</form>

On the receiving end, verify it before doing anything else:

if ( ! isset( $_POST['sb_widget_nonce'] )
    || ! wp_verify_nonce( $_POST['sb_widget_nonce'], 'sb_save_widget' ) ) {
    wp_die( 'Security check failed.' );
}

For classic admin-post.php handlers, check_admin_referer( 'sb_save_widget', 'sb_widget_nonce' ) does the same verification and dies automatically on failure, which is why it shows up so often in WordPress core and plugin code. For AJAX handlers, use the AJAX-specific counterpart, check_ajax_referer(), which returns a proper error response instead of a raw admin screen.

The capability half, and why the nonce alone is not enough

A nonce only proves the request came from your own site’s UI for a logged-in session. It says nothing about whether that particular user should be allowed to perform the action. If your handler stops at nonce verification, any logged-in user, including a subscriber, could still trigger an action meant for administrators, as long as they could somehow get the correct nonce for their own session (which is trivial if they can view the form that generates it).

That is what current_user_can() is for. It checks the current user’s role and capabilities against a specific permission:

if ( ! current_user_can( 'manage_options' ) ) {
    wp_die( 'You do not have permission to do this.', 403 );
}

Choose the narrowest capability that fits the action. manage_options is appropriate for site-wide settings, but edit_posts, edit_others_posts, or a custom capability you register yourself will usually be more accurate for content-related actions. Reaching for manage_options everywhere is a common shortcut that quietly grants too much power to the wrong roles.

Putting both checks together

The pattern is the same shape everywhere a request changes state, whether it is a settings form, an AJAX call, or a REST API route. Nonce first, capability second, then do the work:

function sb_handle_save_widget() {
    check_admin_referer( 'sb_save_widget', 'sb_widget_nonce' );

    if ( ! current_user_can( 'edit_theme_options' ) ) {
        wp_die( 'You do not have permission to do this.', 403 );
    }

    $title = sanitize_text_field( $_POST['widget_title'] ?? '' );
    update_option( 'sb_widget_title', $title );

    wp_safe_redirect( admin_url( 'admin.php?page=sb-widget&updated=1' ) );
    exit;
}
add_action( 'admin_post_sb_save_widget', 'sb_handle_save_widget' );

For AJAX, the pattern looks like this, with check_ajax_referer() handling the nonce and dying with a JSON-friendly response on failure:

function sb_ajax_save_note() {
    check_ajax_referer( 'sb_save_note', 'nonce' );

    if ( ! current_user_can( 'edit_posts' ) ) {
        wp_send_json_error( 'Insufficient permissions', 403 );
    }

    $note = sanitize_textarea_field( $_POST['note'] ?? '' );
    update_user_meta( get_current_user_id(), 'sb_note', $note );

    wp_send_json_success();
}
add_action( 'wp_ajax_sb_save_note', 'sb_ajax_save_note' );

REST API routes use a slightly different mechanism. Instead of manually checking a nonce inside the callback, you supply a permission_callback when registering the route, and the REST infrastructure verifies the X-WP-Nonce header for you when it is present:

register_rest_route( 'sb/v1', '/note', array(
    'methods'             => 'POST',
    'callback'            => 'sb_rest_save_note',
    'permission_callback' => function () {
        return current_user_can( 'edit_posts' );
    },
) );

Notice that the capability check still belongs in the permission_callback, separate from nonce handling. That separation of concerns, authenticity in one place, authorization in another, is the whole point of the pattern.

Mistakes that quietly break the protection

  • Checking capability but skipping the nonce. This still leaves you open to CSRF, since a logged-in admin can be tricked into firing the request from a malicious page.
  • Checking the nonce but skipping the capability. This still leaves you open to privilege escalation, since any logged-in user who can reach the form can trigger the action.
  • Reusing one nonce action name across multiple unrelated handlers. Nonces are meant to be scoped to a specific action; reusing the same action string everywhere weakens the guarantee that the nonce matches the intended operation.
  • Trusting nonces on GET requests that change data. State-changing actions belong on POST (or a dedicated REST method), not on a link that a crawler or a proxy might prefetch.
  • Forgetting that nonces expire. WordPress nonces are valid for a limited window (by default around a day, in two 12-hour ticks). Long-lived tabs left open can hit a stale nonce; handle that failure gracefully instead of assuming malice.

Testing your work

Log in as a low-privilege role, subscriber or contributor, and try to trigger the action directly, either by crafting the request in a browser console or with a tool like curl, bypassing your own UI. If the capability check is correct, you should get a clean permission error every time, regardless of whether you have a valid nonce. Separately, test with an expired or missing nonce while logged in as an administrator, and confirm the request is rejected rather than silently succeeding. Both tests should fail loudly. If either one succeeds when it should not, the corresponding check is missing or misconfigured somewhere in the request path.

The takeaway

Every piece of custom code that changes data on your WordPress site should answer two separate questions before doing anything: did this request genuinely come from my site’s own interface, and is this particular user allowed to do this particular thing. A nonce answers the first question. current_user_can() answers the second. Neither one substitutes for the other, and both are cheap to add. Make the pairing a reflex in every form handler, AJAX action, and REST callback you write, and CSRF and privilege escalation stop being a risk you have to think about case by case.