You added a nonce to your form. You verify it on submit. The question is whether you are now secure, and the answer is that you have closed exactly one door, and it is not the door most people think they closed.
A nonce answers “did this request come from a form I served to this user recently?” It does not answer “is this user allowed to do this?” Those are two different questions, they are checked by two different functions, and neither one substitutes for the other.
What a nonce actually is
The name is borrowed from cryptography, where a nonce is used once. WordPress nonces are not used once. They are short-lived tokens tied to a user session and an action name, and the same token is valid for a window of time.
What that buys you is protection against a specific attack: a third-party site causing a logged-in user’s browser to submit a request to your site without the user intending it. Because the attacker’s page cannot read a token that your site generated for that user, it cannot include a valid one, and the request fails verification.
That is the whole scope. Cross-site request forgery, and nothing else.
What a nonce does not do
It does not identify the user. WordPress already did that, from the auth cookie, before your code ran.
It does not authorize the user. A subscriber who loads a page containing your form gets a perfectly valid nonce with it. If your handler checks the nonce and then deletes a post, the subscriber can delete posts, and every token in the request was genuine.
It does not make the input safe. Nonce verification says nothing about the contents of $_POST. Sanitizing and escaping are a separate layer and still apply in full.
It is not a secret to protect. A nonce is delivered to the user in the page. It has no value outside that user’s session.
The pair, written out
Form side:
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="acme_save">
<?php wp_nonce_field( 'acme_save_item', 'acme_nonce' ); ?>
<input type="text" name="title">
<button type="submit">Save</button>
</form>
Handler side:
add_action( 'admin_post_acme_save', 'acme_handle_save' );
function acme_handle_save() {
// 1. Was it my form?
if ( ! isset( $_POST['acme_nonce'] )
|| ! wp_verify_nonce( $_POST['acme_nonce'], 'acme_save_item' ) ) {
wp_die( esc_html__( 'Security check failed.', 'acme' ), 403 );
}
// 2. Is this person allowed?
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'You cannot do that.', 'acme' ), 403 );
}
// 3. Is the input acceptable, and clean?
$title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) );
// ... do the work
}
Three checks, three questions, in that order. Remove any one and the handler has a hole in it.
check_admin_referer( 'acme_save_item', 'acme_nonce' ) is the shorthand for step one on admin screens: it verifies and dies on failure in a single call. check_ajax_referer is its Ajax equivalent. Both still leave step two to you.
The capability check is the one that matters
If you have to choose which of the two to get right, choose this one, because failing it is the one that lets the wrong person do the thing.
current_user_can( 'edit_posts' ); // a general capability
current_user_can( 'edit_post', $post_id ); // this specific post
current_user_can( 'manage_options' ); // administrator territory
Three things go wrong here regularly.
Checking a role instead of a capability. Roles are bundles of capabilities and site owners change them. current_user_can( 'administrator' ) happens to work on a default install and is not what the function is for. Check the capability that describes the action.
Forgetting the object. edit_posts is “can edit posts in general”. edit_post with an id is “can edit this one”. An author who may edit their own work passes the first and should fail the second on somebody else’s post. If your action targets a specific object, pass the object.
Using is_admin() as a permission check. It reports whether the request is admin-side. A logged-out visitor can reach admin-side endpoints. It is a routing signal, not an authorization one.
Ajax and REST
Ajax goes through admin-ajax.php, and there are two hooks: wp_ajax_{action} for logged-in users and wp_ajax_nopriv_{action} for everyone else. Registering nopriv on a handler that writes data is how a privileged action gets exposed to the public without anyone noticing, because the handler itself looks fine. Register nopriv only when the action genuinely is public.
REST has its own mechanism. register_rest_route takes a permission_callback, and that callback is where the capability check belongs. The cookie-authenticated REST path also expects the X-WP-Nonce header carrying a nonce created for the wp_rest action; wp_localize_script is the usual way to hand that to your JavaScript.
Expiry, and the failure people report as a bug
Nonces expire. A form left open overnight will fail verification when it is finally submitted, and the user loses their input.
That is intended behavior, not a bug, but it is worth handling. wp_verify_nonce returns 1 for a nonce in the first half of its lifetime and 2 for one in the second half, and false when it is invalid or expired. If you have a long-lived form, refresh the nonce in the background, or fail with a message that says the session expired rather than a blank security error that reads like an accusation.
One more thing that is not a nonce
Do not put anything genuinely secret into a form, a template, or a JavaScript bundle in order to prove identity. A nonce is safe to send to the browser precisely because it is worthless to anyone else. An API key is not, and the reasoning about where credentials may and may not live is the subject of where an API key goes and every place it must not.
The short version
- Nonce: was this my form, for this user, recently. That is all.
- Capability: is this user allowed. Pass the object id when the action targets one.
- Sanitize and escape regardless. Neither check touches the data.
- Both, in that order, in every handler that changes anything.

