How to avoid CSRF in admin-post.php actions in WordPress

Contents

Introduction

This article explains in depth how to avoid Cross-Site Request Forgery (CSRF) when registering and handling actions via WordPresss admin-post.php endpoint. It covers the threat model, how admin-post.php works, proven protection patterns for both authenticated and unauthenticated users, examples (complete code), common mistakes, and additional hardening measures.

Why CSRF matters for admin-post.php

admin-post.php is a central WordPress endpoint that plugins and themes commonly use to receive form submissions and other POST actions. Because admin-post.php executes code based on the action request parameter, an attacker who can make a victims browser submit a request to that endpoint while the victim is authenticated can trigger potentially dangerous operations (changing settings, performing stateful actions, etc.). Defending these handlers against CSRF is essential.

How admin-post.php handlers are registered

WordPress dispatches requests to admin-post.php by looking for either admin_post_{action} (for logged-in users) or admin_post_nopriv_{action} (for non-logged-in visitors). Example:

add_action(admin_post_my_action, my_action_handler)           // logged-in
add_action(admin_post_nopriv_my_action, my_action_handler)    // public

Threat model and guidance summary

  • Assume attacker can make victim visit a page that triggers a form submission or requests admin-post.php.
  • Primary defenses: use nonces or equivalent single-use tokens, verify request method (POST), check capabilities for authenticated actions, apply proper input sanitization and output escaping, and always perform a redirect/response and exit.
  • For logged-in contexts: use WordPress nonces (wp_nonce_field / check_admin_referer or wp_verify_nonce).
  • For public forms: nonces are less reliable for unauthenticated users implement a token stored server-side (transient, option, or DB row) or use reCAPTCHA/other verification, and validate it on submit.

Best practices checklist

  1. Use POST for state-changing operations.
  2. Require and verify a nonce or token for every admin-post.php handler.
  3. For logged-in users, use check_admin_referer or wp_verify_nonce.
  4. For unauthenticated submissions, use a server-generated token (transient) or other verification mechanism avoid relying solely on nonces intended for authenticated sessions.
  5. Check capabilities with current_user_can() when the action should only be available to particular user roles.
  6. Verify the request method explicitly (e.g., _SERVER[REQUEST_METHOD] === POST).
  7. Sanitize and validate all input, then escape output as needed.
  8. After processing, use wp_safe_redirect() or wp_redirect() followed by exit to prevent further execution.

Logged-in user example (recommended pattern)

This example demonstrates a typical plugin or theme pattern: the frontend (or admin) form includes a nonce field produced by wp_nonce_field(), and the handler uses check_admin_referer() to validate the nonce and referer. The handler also checks capability, sanitizes input, and redirects safely.

Form output (HTML) — including nonce

>

Handler (PHP) — validate nonce, capability, method, sanitize, redirect

// Hook for logged-in users
add_action(admin_post_my_invoice_action, my_invoice_action_handler)

function my_invoice_action_handler() {
    // Only allow POST
    if ( strtolower( _SERVER[REQUEST_METHOD] ) !== post ) {
        wp_die( Invalid request method, Bad request, array( response => 405 ) )
    }

    // Check nonce and referer. Will die() on failure by default.
    check_admin_referer( my_invoice_action, my_invoice_nonce )

    // Capability check: ensure user is allowed to perform this action
    if ( ! current_user_can( edit_posts ) ) {
        wp_die( You do not have permission to perform this action, Forbidden, array( response => 403 ) )
    }

    // Sanitize incoming values
    amount = isset( _POST[amount] ) ? sanitize_text_field( wp_unslash( _POST[amount] ) ) : 

    // Validate / process the data safely
    amount = floatval( amount ) // example validation

    // Do your stateful operation here, e.g. update option, create post, call external API

    // Redirect back to a safe page (whitelist or validate redirect)
    redirect = isset( _POST[redirect_to] ) ? wp_validate_redirect( esc_url_raw( _POST[redirect_to] ), admin_url() ) : admin_url()
    wp_safe_redirect( redirect )
    exit // always exit after redirect
}

Notes about check_admin_referer and nonces

  • check_admin_referer() validates a nonce and referer it dies with a message on failure (you can catch or override behavior if needed).
  • wp_verify_nonce() returns false/1/2 allowing custom handling it does not check referer. Combine it with manual checks when appropriate.
  • WordPress nonces are time-limited (default 12 hours) and user-session related they are primarily designed for protecting logged-in actions.

Handling public (non-authenticated) forms safely

WordPress nonces are not always suitable for non-logged-in users because nonces associate with a user or session. For public forms (contact forms, public submissions), use a server-generated single-use token stored server-side (transient, option, custom table) and place it into the form as a hidden field. Verify and delete the token when the form is submitted. This provides CSRF protection for unauthenticated users.

Public form — generation with transient token

// When rendering the public form, generate a one-time token
token = wp_generate_password( 20, false ) // secure random string
set_transient( public_form_token_ . token, 1, 15  MINUTE_IN_SECONDS ) // expire in 15 minutes
?>
> >

Public handler — validate token, method, sanitize, delete token

add_action(admin_post_nopriv_public_contact_form, public_contact_form_handler)
add_action(admin_post_public_contact_form, public_contact_form_handler) // in case logged-in also

function public_contact_form_handler() {
    if ( strtolower( _SERVER[REQUEST_METHOD] ) !== post ) {
        wp_die( Invalid request method, Bad request, array( response => 405 ) )
    }

    // Verify token exists and is valid
    if ( empty( _POST[public_token] )  ! is_string( _POST[public_token] ) ) {
        wp_die( Missing token, Bad request, array( response => 400 ) )
    }

    token = sanitize_text_field( wp_unslash( _POST[public_token] ) )
    transient_key = public_form_token_ . token

    if ( ! get_transient( transient_key ) ) {
        wp_die( Invalid or expired token, Forbidden, array( response => 403 ) )
    }

    // Token is single-use: delete it right away
    delete_transient( transient_key )

    // Process and sanitize your form data
    message = isset( _POST[message] ) ? sanitize_textarea_field( wp_unslash( _POST[message] ) ) : 

    // Perform operation: e.g., send email, store submission

    wp_safe_redirect( home_url( /thank-you/ ) )
    exit
}

Alternative: use REST API or AJAX with proper nonces

For modern plugins/themes, consider using the REST API with authentication (cookie-based WP REST nonces or application passwords) or admin-ajax.php with check_ajax_referer(). REST endpoints are easier to scope and protect with permission callbacks. When using admin-ajax.php or the REST API from JavaScript, use wp_localize_script() or wp_add_inline_script() to pass a nonce created with wp_create_nonce() and validate with check_ajax_referer() or in REST with permission callbacks that call wp_verify_nonce where appropriate.

AJAX example (authenticated) — JS PHP

// JS: send nonce via AJAX
jQuery.post(ajaxurl, {
    action: my_ajax_action,
    _ajax_nonce: myScript.nonce,
    data: { / payload / }
}, function(response) {
    console.log(response)
})
// PHP: localize nonce
wp_localize_script(my-script-handle, myScript, array(
    nonce => wp_create_nonce( my_ajax_action_nonce )
))

// PHP: AJAX handler
add_action(wp_ajax_my_ajax_action, my_ajax_action_handler)

function my_ajax_action_handler() {
    check_ajax_referer( my_ajax_action_nonce, _ajax_nonce ) // dies on failure
    // capability check
    if ( ! current_user_can( edit_posts ) ) {
        wp_send_json_error( Forbidden, 403 )
    }
    // process...
    wp_send_json_success( array( ok => true ) )
}

Common pitfalls and mistakes

  • Relying solely on referrer header checks — referrers may be stripped or spoofed in some scenarios. Use nonces/tokens in addition to referer checks where possible.
  • Using GET requests for state-changing actions — GET should be safe and idempotent. Use POST for changes.
  • Not checking request method — explicitly require POST for handlers that change state.
  • Not checking capabilities — just because a user is logged in does not mean they should perform every action.
  • Using nonces incorrectly for public forms — consider single-use transient tokens for unauthenticated users.
  • Failing to exit after redirect — leaving execution running may cause unexpected behavior.
  • Breaking CSRF protections by exposing token generation endpoints to attackers — keep token creation tied to a rendered page that the legitimate user loads (or protect token issuance accordingly).

Additional hardening and operational measures

  • Use secure cookies (Secure and HttpOnly) and set SameSite attributes where appropriate to reduce cookie-based CSRF risks for authenticated sessions. WordPress core manages many auth cookies review wp_set_auth_cookie and related filters to apply SameSite if required.
  • Use Content Security Policy (CSP) where feasible to reduce the ability of third-party pages to submit forms or run exploit scripts.
  • Monitor and log suspicious requests to admin-post.php and rate-limit unusual activity.
  • Prefer REST API endpoints with fine-grained permission callbacks over generic admin-post.php handlers for complex integrations and external access.
  • Keep WordPress core, plugins, and themes updated. Use minimal privileges principle for users and service accounts.

Troubleshooting checklist

  1. If nonce checks are failing frequently, ensure the page that renders the form is not cached: nonces are per-session/time-limited and caching can serve stale tokens. Use nonces only in non-cacheable parts or embed transient tokens that are safe for caching strategies.
  2. If requests from legitimate clients are blocked, confirm the request method, presence of the token/nonce field name, and that you are not double-escaping or altering the token value in templates.
  3. When using check_admin_referer(), remember it can call wp_die() on failure consider using wp_verify_nonce for non-fatal handling.

Quick reference code snippets

Minimal patterns:

  • Generate nonce for a form:
wp_nonce_field( action_name, nonce_field_name )
  • Verify and die on bad nonce:
check_admin_referer( action_name, nonce_field_name )
  • Verify without automatic die():
if ( ! isset( _POST[nonce_field_name] )  ! wp_verify_nonce( _POST[nonce_field_name], action_name ) ) {
    // handle failure
}

References and further reading

Conclusion

Protecting admin-post.php handlers from CSRF requires a disciplined approach: require POST, use nonces for authenticated users, use server-side single-use tokens for public forms, validate capabilities and inputs, and always redirect and exit after processing. Following the patterns and examples in this article will significantly reduce CSRF risk when using admin-post.php.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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