If you have written more than one WordPress admin form, you have met the WordPress nonce. It is the strange hidden field and the check_admin_referer() line that everyone copies and nobody quite explains. This article explains it in plain words, shows the PHP and JavaScript to use it properly, and covers the mistakes that turn a small security helper into a false sense of safety.
It is aimed at plugin and theme developers, but you do not need to be an expert. If you can write an add_action(), you can follow along.
What a WordPress Nonce Actually Is
The word "nonce" means "number used once" in cryptography. The WordPress version is not that. The WordPress developer handbook says it plainly: despite the name it is a hash of numbers and letters, and it is not checked for one-time use, so it does not prevent replay attacks. You can read that on the official WordPress nonces documentation.
What you get is a short-lived token generated from three things:
- The user (and their session), so one person's nonce does not work for another.
- The action, a string you choose, such as
save_my_settings, so a nonce for one job cannot be used for another. - The time, so the token goes stale after a while.
When the form is submitted, WordPress calculates what the token should be for this user, action and time, and compares it with what arrived. A match means the request very likely came from a page your site produced for this person.
Think of it as a ticket stub with your name, the event and today's date on it. It proves you were handed it by the venue. It does not prove you are allowed backstage.
What a Nonce Protects Against (and What It Does Not)
It Protects Against CSRF
Cross-site request forgery is when an attacker tricks a signed-in user into sending a request they never meant to. Imagine an administrator is signed in to your site, then opens a malicious page in another tab. That page quietly submits a form to yoursite.com/wp-admin/ such as "delete this user". The browser attaches the admin's cookies, so without any extra check, the site obeys.
A nonce breaks this. The attacker's page cannot know the nonce for that admin and that action, so the forged request fails verification.
It Does Not Protect Authorization
This is the part people get wrong. The official documentation states that nonces should never be relied on for authentication, authorization or access control, and tells you to use current_user_can() to protect functions.
A nonce answers "did this request come from my page?" It does not answer "is this person allowed to do this?" A subscriber can load a page, get a perfectly valid nonce for an action and submit it. If your handler only checks the nonce, you have just let a subscriber do an admin job.
Rule of thumb: nonce check plus capability check, every time. No exceptions.
It also does not clean your data. You still sanitize input, escape output and use $wpdb->prepare() for queries. (If you want a quick tour of how these mistakes show up in the wild, see our article on WordPress plugin vulnerabilities.)
Creating and Checking a Nonce in PHP
WordPress gives you a small set of functions. Here is what each does.
| Function | Use it to |
|---|---|
wp_create_nonce( $action ) | Return a nonce string for an action |
wp_nonce_field( $action, $name ) | Print hidden form fields containing a nonce |
wp_nonce_url( $url, $action ) | Add a nonce to a link |
check_admin_referer( $action, $name ) | Verify a nonce (and referrer) in an admin request, die on failure |
check_ajax_referer( $action, $name ) | Verify a nonce in an AJAX request |
wp_verify_nonce( $nonce, $action ) | Verify a nonce yourself and handle the result |
A Form With a Nonce
Here is a complete settings form handler. Notice the three layers: nonce, capability, then sanitizing.
<?php
// Rendering the form.
function myplugin_render_form() {
?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="myplugin_save">
<?php wp_nonce_field( 'myplugin_save_settings', 'myplugin_nonce' ); ?>
<input type="text" name="myplugin_title" value="">
<?php submit_button( 'Save' ); ?>
</form>
<?php
}
// Handling the submit.
add_action( 'admin_post_myplugin_save', 'myplugin_handle_save' );
function myplugin_handle_save() {
check_admin_referer( 'myplugin_save_settings', 'myplugin_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do that.', 'myplugin' ), 403 );
}
$title = isset( $_POST['myplugin_title'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_title'] ) )
: '';
update_option( 'myplugin_title', $title );
wp_safe_redirect( admin_url( 'admin.php?page=myplugin&updated=1' ) );
exit;
}
wp_nonce_field() prints two hidden inputs: your nonce under the name you chose, and a _wp_http_referer field. check_admin_referer() reads them back. If the check fails, WordPress stops with the familiar "The link you followed has expired" screen.
A Link With a Nonce
For actions triggered by a link, such as "delete this item", use wp_nonce_url():
$delete_url = wp_nonce_url(
admin_url( 'admin.php?page=myplugin&do=delete&item=42' ),
'myplugin_delete_42'
);
Then in the handler:
check_admin_referer( 'myplugin_delete_42' );
Notice the action name includes the item ID. That makes the nonce specific to this one thing. It is a small touch that makes it harder to reuse a nonce from one item on another.
Checking by Hand
When you need to decide what happens on failure instead of dying, use wp_verify_nonce():
$nonce = isset( $_POST['myplugin_nonce'] )
? sanitize_text_field( wp_unslash( $_POST['myplugin_nonce'] ) )
: '';
if ( ! wp_verify_nonce( $nonce, 'myplugin_save_settings' ) ) {
wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
}
It returns 1 if the nonce is from the first half of its life, 2 if from the second half and false if invalid. Treat any truthy value as a pass.
Using a WordPress Nonce With AJAX and JavaScript
For admin-ajax requests, create the nonce in PHP and hand it to your script with wp_localize_script() or wp_add_inline_script().
add_action( 'admin_enqueue_scripts', function () {
wp_enqueue_script( 'myplugin-admin', plugins_url( 'admin.js', __FILE__ ), array(), '1.0.0', true );
wp_localize_script( 'myplugin-admin', 'myplugin', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_ajax' ),
) );
} );
add_action( 'wp_ajax_myplugin_do_thing', function () {
check_ajax_referer( 'myplugin_ajax', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( array( 'message' => 'Not allowed.' ), 403 );
}
wp_send_json_success( array( 'ok' => true ) );
} );
And the JavaScript:
const body = new URLSearchParams( {
action: 'myplugin_do_thing',
nonce: myplugin.nonce,
} );
fetch( myplugin.ajaxUrl, { method: 'POST', body } )
.then( ( response ) => response.json() )
.then( ( result ) => console.log( result ) );
check_ajax_referer() dies by default when the check fails. Pass false as the third argument if you would rather get a return value and send your own JSON error.
The REST API and the wp_rest Nonce
(For the bigger picture beyond nonces, see WordPress REST API security.) If you use the REST API from JavaScript on a page where the user is signed in, WordPress authenticates them by cookie. To guard against CSRF, it also requires a nonce created with the action wp_rest.
According to the REST API authentication handbook, you can send it in the X-WP-Nonce header or as a _wpnonce parameter. The header is the more reliable choice, especially for DELETE requests. And without a valid nonce, the API sets the current user to 0, so the request is treated as logged out even though you are signed in.
wp_localize_script( 'myplugin-app', 'mypluginRest', array(
'root' => esc_url_raw( rest_url() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
fetch( mypluginRest.root + 'myplugin/v1/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': mypluginRest.nonce,
},
body: JSON.stringify( { title: 'Hello' } ),
} );
On the server side, you still set a permission_callback on your route that checks capabilities. The wp_rest nonce only proves the cookie request is intended. It is not a permission check.
register_rest_route( 'myplugin/v1', '/items', array(
'methods' => 'POST',
'callback' => 'myplugin_create_item',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
) );
How Long Does a WordPress Nonce Last?
By default, a nonce is valid for one day, and you can change that with the nonce_life filter, which takes seconds. The handbook adds the detail most people miss: WordPress uses two 12-hour ticks and accepts a nonce from the current tick or the previous one. With the default setting, a nonce's real lifetime is therefore between 12 and 24 hours.
add_filter( 'nonce_life', function () {
return 4 * HOUR_IN_SECONDS;
} );
Be careful with this filter. It applies to every nonce on the site, including those made by other plugins, so shortening it can make other people's forms fail more often. A longer life gives attackers more time. For most sites, the default is the sensible choice.
The practical consequence for users: if someone leaves an edit screen open overnight and then clicks Save, the nonce may have expired. WordPress's admin handles some of this by refreshing nonces through the Heartbeat API, but your own custom pages may not.
Caching Gotchas
This is the problem that causes the most head-scratching in real projects.
A nonce belongs to a specific user (and for logged-out visitors, to a shared "no user" identity) and a limited time window. A full-page cache stores a page's HTML and serves the same copy to many people. If that copy contains a nonce, everyone after the first visitor receives a token that was made for someone else, or that has since expired.
Typical symptoms:
- A front-end form that works for you but fails for visitors.
- "The link you followed has expired" or a plain
403on submit. - A form that works for a few hours after the cache is cleared, then breaks.
- AJAX calls that fail only for logged-out users.
How to handle it:
- Do not put nonces in cacheable HTML for public forms. Fetch a fresh nonce with a small request after the page loads, then add it to the form with JavaScript.
- Exclude the pages from the cache if they must contain a nonce in the markup.
- Do not use a nonce as your only protection on public forms. For logged-out visitors, a nonce is a weak signal because there is no user to tie it to. Use rate limiting or CAPTCHA for spam and abuse.
- Shorten cache lifetimes below the nonce lifetime if a cached page must carry a nonce. Anything past the nonce lifetime is guaranteed stale.
Also remember a nonce does not protect public, logged-out actions the way it protects signed-in ones. It was designed around a logged-in user.
Common Mistakes With Nonces
- Using the nonce as a permission check. Always add
current_user_can(). - Using the same action string everywhere. Use specific names, ideally with an object ID, such as
myplugin_delete_42. - Forgetting to verify on the handler. A form with
wp_nonce_field()and no check in the handler is decoration. - Verifying only when the field is present.
if ( isset( $_POST['n'] ) && wp_verify_nonce( ... ) )quietly skips the check when someone omits the field. Fail when it is missing. - Using a nonce on a
GETrequest that changes data. Better to usePOSTfor changes. If you must use a link, usewp_nonce_url()and verify it. - Printing a nonce into a cached public page. See the section above.
- Not unslashing and sanitizing the nonce value before passing it to
wp_verify_nonce(). - Putting nonces in URLs that get shared. Nonces in a link may end up in logs, history and referrer headers. Keep their life short and do not treat them as secrets.
- Assuming a nonce stops replay. It is not one-time. Within its lifetime, the same nonce works more than once.
A Quick Word on Stack Guard for Developers
If you build or maintain sites that run Stack Guard, the part that will interest you is the Stack Guard developer reference. It lists the plugin's public actions and filters, such as hooks that fire on sign-in lockouts, bans, scan results and settings changes, along with the wp-config.php constants and WP-CLI commands it supports. That lets you connect your own logging, alerts or tooling to what the plugin does. There is a page for developers that explains how it fits in a workflow too.
On the hardening side, Hardening can switch off the built-in theme and plugin code editors, so a stolen admin session cannot be used to write code into your site (see the admin protection documentation). That is a good companion to nonces and capability checks: they stop forged and unauthorized requests, and this limits the damage if an account is taken over anyway. Short sessions help in the same way, and our guide to WordPress auto logout shows how to set them up.
A Checklist You Can Copy
For any handler that changes something:
- Output the nonce with
wp_nonce_field(),wp_nonce_url()orwp_create_nonce(). - Verify it with
check_admin_referer(),check_ajax_referer()orwp_verify_nonce(), and fail if it is missing. - Check the user's capability with
current_user_can(). - Sanitize every input and use
$wpdb->prepare()for queries. - Escape every output.
- For REST routes, use a
permission_callbackand send thewp_restnonce inX-WP-Nonce. - Keep nonces out of cached public pages.
Do those seven things consistently and you will have covered most of the ways a WordPress nonce gets misused.