Nonces & CSRF protection in WordPress
When to use this skill
Use this skill whenever code accepts or acts on a request that changes state or performs a privileged action. Concretely:
- HTML
<form>submissions (admin or front-end) handled viaadmin-post.php,admin_post_*, or a settings page. admin-ajax.phphandlers (wp_ajax_*,wp_ajax_nopriv_*) andfetch()/jQuery AJAX.- Action links such as "Delete", "Approve", "Activate" that carry a query argument.
- Settings API pages (the Settings API adds a nonce automatically — verify you are not bypassing it).
- Any custom endpoint reached over
$_GET/$_POST/$_REQUEST.
If a request only reads public data and changes nothing, a nonce is not required — but the moment it writes, deletes, or triggers a side effect, it is mandatory.
A nonce is not authentication and not authorization. It proves the request came from a page your site generated, defeating CSRF. You still need a capability check (
current_user_can) to prove the user is allowed to do the thing. Always use both. See thecapability-permission-checksskill.
Core principles (and why they matter)
- Every state-changing request needs a nonce. CSRF works by tricking a logged-in admin's browser into submitting a forged request. A nonce the attacker cannot guess breaks that attack.
- Nonce + capability are a pair, never a substitute. The nonce says "this request came from us"; the capability check says "this user may do this." A nonce alone lets any logged-in subscriber perform admin actions; a capability check alone leaves you open to CSRF.
- Tie the nonce to a specific action. Use a unique, descriptive action string
(e.g.
delete_widget_42) rather than a generic one. A nonce scoped to "delete widget 42" cannot be replayed to delete widget 99 or to do something unrelated. - Verify on the server, every time. Generating a nonce does nothing on its own. The security comes from verifying it on the receiving side before acting.
- Fail closed. If verification fails, stop —
wp_die(), return a 403, or send a JSON error. Never fall through to the action. - Nonces expire (default ~24h) and are per-user. They are single-action tokens, not long-lived secrets. Don't store or reuse them; regenerate per page render.
Step-by-step implementation
- Generate the nonce where the request originates:
- In a form:
wp_nonce_field( 'my_action', 'my_nonce' )(prints a hidden field). - In a URL:
wp_nonce_url( $url, 'my_action', 'my_nonce' ). - For JS/AJAX:
wp_create_nonce( 'my_action' ), passed to the script viawp_localize_script()orwp_add_inline_script().
- In a form:
- Send it with the request (hidden field, query arg, or AJAX payload / header).
- Verify it on the server first, before reading other input or acting:
- Form via
admin-post.php:check_admin_referer( 'my_action', 'my_nonce' ). - AJAX:
check_ajax_referer( 'my_action', 'nonce' ). - Manual / REST-ish:
wp_verify_nonce( $nonce, 'my_action' )and branch on the result.
- Form via
- Check capability immediately after:
if ( ! current_user_can( 'manage_options' ) ). - Then
wp_unslash()+ sanitize the input, do the work, and escape any output. - Fail closed on any failure with
wp_die()orwp_send_json_error().
Supporting references
| Reference | Load when |
|---|---|
| Nonce / CSRF verification checklist | Before final verification of the nonce / csrf verification controls. |
| Secure AJAX nonce flow | Implementing the privileged AJAX flow from nonce generation through verification, authorization, and JSON response. |
Common AI mistakes / anti-patterns
Mistake 1 — Verifying the nonce but skipping the capability check
// ❌ Insecure: nonce proves the request shape, NOT that the user is allowed.
add_action( 'admin_post_delete_thing', function () {
check_admin_referer( 'delete_thing' );
delete_thing( absint( $_POST['id'] ) ); // any logged-in user can reach this
} );
// ✅ Secure: nonce AND capability.
add_action( 'admin_post_delete_thing', function () {
check_admin_referer( 'delete_thing' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'my-plugin' ), 403 );
}
delete_thing( absint( wp_unslash( $_POST['id'] ?? 0 ) ) );
} );
Mistake 2 — Reading wp_verify_nonce() as a boolean and ignoring the result
wp_verify_nonce() returns 1, 2, or false — not a clean boolean, and crucially
the call has no effect unless you branch on it.
// ❌ Insecure: result is computed and thrown away; the action always runs.
wp_verify_nonce( $_POST['my_nonce'], 'my_action' );
save_settings();
// ✅ Secure: branch on the result and fail closed.
$nonce = isset( $_POST['my_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['my_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'my_action' ) ) {
wp_die( esc_html__( 'Security check failed.', 'my-plugin' ), 403 );
}
save_settings();
Mistake 3 — Generic / reused action strings
// ❌ Insecure: a single global action lets a valid nonce be replayed across endpoints.
wp_nonce_field( 'nonce' ); // action = "nonce"
check_admin_referer( 'nonce' );
// ✅ Secure: specific, scoped action string (include the object id when relevant).
wp_nonce_field( 'delete_widget_' . $widget_id, 'widget_nonce' );
check_admin_referer( 'delete_widget_' . $widget_id, 'widget_nonce' );
Mistake 4 — wp_ajax_nopriv_* for a privileged action
wp_ajax_nopriv_* fires for logged-out visitors. Wiring a sensitive action there
exposes it to the public.
// ❌ Insecure: settings save reachable by anonymous users.
add_action( 'wp_ajax_nopriv_save_api_key', 'save_api_key' );
// ✅ Secure: privileged actions use wp_ajax_* only, plus nonce + capability inside.
add_action( 'wp_ajax_save_api_key', 'save_api_key' );
function save_api_key() {
check_ajax_referer( 'save_api_key', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Forbidden' ), 403 );
}
// ...sanitize + save...
wp_send_json_success();
}
Mistake 5 — Echoing the nonce without escaping, or building it by hand
// ❌ Insecure / fragile: manual markup, unescaped output.
echo '<input type="hidden" name="n" value="' . wp_create_nonce( 'act' ) . '">';
// ✅ Secure: let core print the (already-escaped) field, or escape explicitly.
wp_nonce_field( 'act', 'n' );
// If you must build a URL by hand, escape it:
$url = wp_nonce_url( admin_url( 'admin-post.php?action=act' ), 'act', 'n' );
echo '<a href="' . esc_url( $url ) . '">' . esc_html__( 'Do it', 'my-plugin' ) . '</a>';
Mistake 6 — Trusting check_ajax_referer() to also authorize
check_admin_referer() and check_ajax_referer() only verify the nonce (and referer).
They do not check capabilities. Add current_user_can() yourself.
Correct code examples
Admin form submitted to admin-post.php
<?php
/**
* Render the settings form.
*/
function my_plugin_render_form() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="my_plugin_save">
<?php wp_nonce_field( 'my_plugin_save', 'my_plugin_nonce' ); ?>
<input type="text" name="api_key" value="">
<?php submit_button( __( 'Save', 'my-plugin' ) ); ?>
</form>
<?php
}
/**
* Handle the submission.
*/
add_action( 'admin_post_my_plugin_save', 'my_plugin_handle_save' );
function my_plugin_handle_save() {
// 1. Verify the nonce (fails closed via wp_die on mismatch).
check_admin_referer( 'my_plugin_save', 'my_plugin_nonce' );
// 2. Verify capability.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'my-plugin' ), 403 );
}
// 3. Unslash + sanitize input.
$api_key = isset( $_POST['api_key'] )
? sanitize_text_field( wp_unslash( $_POST['api_key'] ) )
: '';
// 4. Do the work.
update_option( 'my_plugin_api_key', $api_key );
// 5. Redirect back safely.
wp_safe_redirect( add_query_arg( 'updated', 'true', wp_get_referer() ) );
exit;
}
For the full AJAX flow (PHP handler + JS), see
references/secure-ajax-handler.php.
Header-based and JSON nonces for REST / modern fetch
When a form is not available, send the nonce in a header or JSON body. The REST API
expects X-WP-Nonce with a nonce created via wp_create_nonce( 'wp_rest' ).
// JavaScript: fetch with X-WP-Nonce header.
fetch( '/wp-json/my-plugin/v1/thing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': myPluginData.nonce, // created with wp_create_nonce( 'wp_rest' )
},
body: JSON.stringify( { id: 1 } ),
} );
// Or use @wordpress/api-fetch, which attaches the REST nonce automatically.
import apiFetch from '@wordpress/api-fetch';
apiFetch( {
path: 'my-plugin/v1/thing',
method: 'POST',
data: { id: 1 },
} );
On the server, the REST API verifies the X-WP-Nonce header when you use a real
permission_callback. Do not use __return_true for state-changing REST routes.
Related: see the ajax-security skill for the admin-ajax flow and the
rest-api-security skill for REST-specific permission handling.
Checklist
Before finishing any request-handling code, confirm:
- Every state-changing request generates a nonce (
wp_nonce_field,wp_nonce_url, orwp_create_nonce). - The action string is specific and includes the target object id where relevant.
- The server verifies the nonce before doing anything else
(
check_admin_referer/check_ajax_referer/ branchedwp_verify_nonce). - A capability check (
current_user_can) runs alongside the nonce check. -
wp_verify_nonce()results are branched on; the action does not run on failure. - Privileged AJAX uses
wp_ajax_*(notwp_ajax_nopriv_*). - On failure the code fails closed (
wp_die, 403, orwp_send_json_error). - Input is
wp_unslash()-ed and sanitized after verification. - Nonce values printed to the page are escaped (or produced by core helpers).