How to protect forms with nonces and capabilities in PHP in WordPress

Contents

Overview: why nonces and capabilities matter for WordPress forms

WordPress nonces and capability checks protect your application from common web attacks and privilege escalation:

  • Nonces (number used once) mitigate Cross-Site Request Forgery (CSRF) by ensuring a request originates from a trusted user and page.
  • Capabilities (current_user_can, user_can) ensure the current user has the right privileges to perform the requested action (authorization).

Never rely on nonces alone for authorization. Nonces protect intent and origin capability checks confirm permissions. Combine them, always sanitize inputs, and escape outputs.

Nonces: what they are and how they work

A WordPress nonce is a keyed token generated for a specific user and action. Nonces are time-limited (default lifetime configurable via the nonce_life filter WordPress default is 24 hours), and are not persistent tokens stored in the database. They are suitable for preventing CSRF in state-changing requests (POST, DELETE, etc.). They are not proper authentication tokens and should not be used as the only protection.

Important properties

  • Action-scoped: you generate nonces for a specific action string verification checks that action.
  • User-scoped: nonces are tied to the current user/session.
  • Time-limited: default lifetime is 24 hours (filterable with nonce_life).
  • Stateless: WordPress does not persist each nonce it derives them from user/session/time and a secret.

Core functions — generation and verification

  • wp_create_nonce(action) — create a nonce string for an action (useful for JavaScript or URLs).
  • wp_nonce_field(action, name, referer, echo) — output a hidden input with the nonce and optionally a referer field (helper for forms).
  • wp_nonce_url(url, action, name) — append a nonce to a URL querystring (useful for links that perform actions).
  • wp_verify_nonce(nonce, action) — low-level verification returns 1 or 2 (valid in current or previous tick) or false.
  • check_admin_referer(action, query_arg) — verifies nonce and referer for admin-posted forms (dies on failure unless you pass false to the third arg).
  • check_ajax_referer(action, query_arg, die) — verifies a nonce used for AJAX requests (often used in admin-ajax.php handlers).

General best practices

  • Only use nonces for actions that change state (create, update, delete) they are not needed for read-only operations.
  • Always check a user capability with current_user_can() for authorization after verifying the nonce.
  • Sanitize all incoming data (sanitize_text_field, sanitize_email, wp_kses_post, etc.).
  • Escape all output (esc_attr, esc_html, esc_url) to avoid XSS.
  • Use POST for state-changing operations prefer not to place critical actions in GET links unless you also validate capabilities and nonces.
  • For AJAX in JavaScript, pass the nonce via a JS object using wp_localize_script or use the X-WP-Nonce header for REST requests.
  • For unauthenticated users, nonces are less reliable consider server-persisted CSRF tokens or reCAPTCHA for anonymous forms.

Examples

The examples below show common patterns: an admin form (server-side processing), a front-end form (shortcode), an admin-ajax AJAX handler (JS PHP), and a REST route. Every code block is wrapped in the specified Enlighter pre tag with the correct language.

1) Admin settings form (wp-admin) with wp_nonce_field and check_admin_referer

This example adds an options page with a form. The form includes a nonce via wp_nonce_field. The form handler verifies the nonce using check_admin_referer and checks capability with current_user_can(manage_options).

Settings saved.

} // Display form value = esc_attr( get_option(my_plugin_text, ) ) ?>
class=regular-text>

Notes

2) Front-end form via shortcode with explicit wp_create_nonce and handling in admin-post.php

A public form handled by admin-post.php (wp-admin/admin-post.php) supports logged-in and logged-out handlers. For logged-in users, nonces work well. For logged-out users, consider extra server-stored tokens or CAPTCHA.


    
method=post>

3) AJAX example (admin-ajax.php): enqueue script, localize nonce, verify server-side

This shows a JS client making an AJAX POST, sending a nonce, and the PHP handler verifying the nonce with check_ajax_referer and checking capabilities.

 admin_url(admin-ajax.php),
        nonce    => wp_create_nonce(my_ajax_action) // server-generated nonce for the action
    ))
})

// AJAX handler for logged-in users
add_action(wp_ajax_my_ajax_action_handler, my_ajax_action_handler)
// Optionally for logged-out users
add_action(wp_ajax_nopriv_my_ajax_action_handler, my_ajax_action_handler)

function my_ajax_action_handler() {
    // Verify nonce (third arg true means die on failure)
    check_ajax_referer(my_ajax_action, security)

    // Capability check (only if action requires it)
    if ( ! current_user_can(edit_posts) ) {
        wp_send_json_error(array(message => Insufficient permissions), 403)
    }

    value = isset(_POST[value]) ? sanitize_text_field(_POST[value]) : 
    // Process...
    wp_send_json_success(array(received => value))
}
?>
// my-ajax.js
jQuery(function(){
    (#my-button).on(click, function(e){
        e.preventDefault()
        .post(MyAjaxData.ajax_url, {
            action: my_ajax_action_handler,
            security: MyAjaxData.nonce, // the nonce field name we check in PHP
            value: (#some-input).val()
        }, function(response){
            if (response.success) {
                console.log(Server replied:, response.data)
            } else {
                console.error(AJAX error, response)
            }
        })
    })
})

Notes for AJAX

4) REST API endpoint: permission_callback and X-WP-Nonce

The REST API has built-in capability to use a nonce passed in the request header X-WP-Nonce. On the JS side, when using the REST client (wp-api or your own code), send the nonce with that header (common pattern is to localize the nonce or use wpApiSettings.nonce). On the PHP side, register your route and use a permission_callback to check capabilities. You can also verify the nonce manually with wp_verify_nonce on the header value if you need custom logic.

 POST,
        callback => myplugin_rest_save,
        permission_callback => myplugin_rest_permissions_check
    ))
})

function myplugin_rest_permissions_check( request ) {
    // 1) Optionally verify the X-WP-Nonce (header)
    header_nonce = 
    if ( isset( _SERVER[HTTP_X_WP_NONCE] ) ) {
        header_nonce = sanitize_text_field( wp_unslash( _SERVER[HTTP_X_WP_NONCE] ) )
    }
    // Verify the nonce created with wp_create_nonce(wp_rest)
    if ( header_nonce  ! wp_verify_nonce( header_nonce, wp_rest ) ) {
        return new WP_Error( rest_nonce_invalid, Invalid nonce, array(status => 403) )
    }

    // 2) Capability check
    if ( ! current_user_can( edit_posts ) ) {
        return new WP_Error( rest_forbidden, You cannot perform this action, array(status => 403) )
    }

    return true
}

function myplugin_rest_save( request ) {
    params = request->get_json_params()
    value = isset(params[value]) ? sanitize_text_field(params[value]) : 

    // Process and return response
    return rest_ensure_response(array(saved => value))
}
?>

Client-side example sending X-WP-Nonce (fetch)

// Suppose you localized MyRest with a nonce and root keys:
fetch(MyRest.root   myplugin/v1/save, {
    method: POST,
    credentials: same-origin, // important to keep cookies  auth
    headers: {
        Content-Type: application/json,
        X-WP-Nonce: MyRest.nonce
    },
    body: JSON.stringify({ value: some data })
}).then(r => r.json()).then(console.log)

Using nonces in URLs (links that perform actions)

If you must include a nonce in a link (for example, to trigger a one-click action), generate it with wp_nonce_url or add wp_create_nonce and append a query arg named _wpnonce (or custom name). On the server verify it with check_admin_referer or wp_verify_nonce.

 post_id), admin_url(admin.php?page=my_page) ), delete_post_ . post_id, _wpnonce )

// Then on handler:
if ( isset(_GET[_wpnonce])  wp_verify_nonce(_GET[_wpnonce], delete_post_ . post_id) ) {
    // check capability
    if ( current_user_can(delete_post, post_id) ) {
        wp_delete_post(post_id)
    }
}
?>

Edge cases and advanced considerations

  1. Nonce lifetime and timing: You can change the default lifetime via the nonce_life filter. Keep in mind wp_verify_nonce accepts previous tick as valid, so slightly more than the configured tick is allowed. If you change lifetime keep UX in mind.
  2. Nonces for anonymous users: Since nonces are tied to user/session, they are not a reliable CSRF defense for anonymous users. Use server-generated tokens persisted to session/transient or use CAPTCHA for spam-sensitive forms.
  3. Do not use nonces as the only authorization: Nonces prove intent, not permission. Always check capabilities for actions that require specific privileges.
  4. Use HTTPS for security-sensitive requests to protect nonces in transit.
  5. Avoid exposing raw database IDs in links without capability checks – always verify the user is allowed to act on the target resource (current_user_can(edit_post, post_id)).
  6. Manage graceful failure: check_admin_referer and check_ajax_referer will by default call wp_die for custom behavior call wp_verify_nonce and handle failures to return JSON errors or redirect with an error notice.

Putting it all together: checklist before deploying a form

Troubleshooting common issues

Further references

Summary

To protect WordPress forms, always combine nonces (for CSRF protection) with capability checks (for authorization), sanitize inputs, and escape outputs. Use wp_nonce_field or wp_create_nonce for generation, check_admin_referer / check_ajax_referer / wp_verify_nonce for verification, and employ REST permission_callback for REST endpoints. For anonymous interactions, use server-persisted CSRF tokens or other anti-spam measures. These practices significantly reduce the risk of CSRF and unauthorized actions in your plugin or theme.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *