Nonces and Capabilities in a Secure WordPress Admin Form

A WordPress nonce proves that a request came from a page your site generated; it does not prove that the requester is allowed to perform the action. A secure admin form therefore needs both nonce verification and a capability check. It also needs strict validation, a safe redirect, and escaped output. This tutorial builds a custom maintenance-note form with admin_post_{$action}, showing exactly where each control belongs and why omitting any one of them weakens the boundary. The complete request path is short enough to audit line by line.
Start with an explicit authorization rule
The example lets trusted administrators store a short internal maintenance note. The manage_options capability is the policy. Use a capability rather than checking a role because sites can rename roles or assign capabilities differently.
function adz_notes_can_manage() {
return current_user_can( 'manage_options' );
}
For other actions, choose the narrowest capability that represents the operation. Editing one post may require current_user_can( 'edit_post', $post_id ), while deleting it requires the corresponding delete capability. Authorization should describe the resource and action, not simply whether the user is logged in.
Render the form with an action and nonce
Post custom admin forms to admin-post.php. The hidden action value tells WordPress which authenticated handler to run. Generate a nonce tied to a specific action string.
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="adz_save_note">
<?php wp_nonce_field( 'adz_save_note', 'adz_note_nonce' ); ?>
<label for="adz-note"><?php esc_html_e( 'Maintenance note', 'adz-notes' ); ?></label>
<textarea id="adz-note" name="note" maxlength="500"></textarea>
<?php submit_button( __( 'Save note', 'adz-notes' ) ); ?>
</form>
A nonce is time-limited and user/session-specific, which helps resist cross-site request forgery. It is not a one-time token in the strict cryptographic sense, and it must never replace authorization.
Route the request through admin-post.php
Register an authenticated action and reject unauthorized users before touching input or data. There is no admin_post_nopriv_ handler because this operation must never be public.
add_action( 'admin_post_adz_save_note', 'adz_notes_handle_save' );
function adz_notes_handle_save() {
if ( ! adz_notes_can_manage() ) {
wp_die(
esc_html__( 'You are not allowed to save this note.', 'adz-notes' ),
'',
array( 'response' => 403 )
);
}
check_admin_referer( 'adz_save_note', 'adz_note_nonce' );
// Validate, save, and redirect below.
}
Capability first or nonce first can both fail safely, but checking authorization immediately makes the policy unmistakable. check_admin_referer() terminates invalid requests; it does not grant permission.
Unslash, validate, then sanitize
WordPress adds slashes to request data. Unslash a scalar before processing it, reject arrays, limit length on the server, and store the sanitized text.
$raw = isset( $_POST['note'] ) && is_string( $_POST['note'] )
? wp_unslash( $_POST['note'] )
: '';
$note = sanitize_textarea_field( $raw );
if ( mb_strlen( $note ) > 500 ) {
wp_die( esc_html__( 'The note is too long.', 'adz-notes' ), '', array( 'response' => 400 ) );
}
update_option( 'adz_maintenance_note', $note, false );
The textarea’s maxlength improves the interface but is not a security control; clients can bypass it. Server-side checks define the real contract. Passing false for autoload is also sensible for an admin-only value that every front-end request does not need.
Redirect safely with a status message
Never leave a successful POST on a refreshable response. Redirect back to an allowlisted admin URL and carry only a small status code.
$url = add_query_arg(
'adz_notice',
'saved',
admin_url( 'options-general.php?page=adz-notes' )
);
wp_safe_redirect( $url );
exit;
On the destination page, compare the status against known values and render a translated, escaped notice. Do not echo arbitrary query-string messages; that turns a convenience into an output-injection risk.
Escape when displaying the saved value
Sanitizing at write time does not remove the requirement to escape at read time. Data can be changed by imports, older versions, database tools, or another plugin.
$note = get_option( 'adz_maintenance_note', '' );
echo '<p>' . nl2br( esc_html( $note ) ) . '</p>';
Choose the escape function for the output context: esc_html() for text, esc_attr() for attributes, esc_url() for URLs, and carefully configured wp_kses() when limited HTML is intentionally supported.
Test the controls independently
- Submit a valid request as an authorized user and confirm one update.
- Remove or alter the nonce and confirm the option is unchanged.
- Use a valid nonce from a user without the capability and confirm a 403 response.
- Send
note[]instead of a string and confirm it is rejected safely. - Send more than 500 characters outside the browser UI.
- Store HTML directly in the database and confirm output still escapes it.
Testing each defense independently prevents a passing happy path from disguising a missing boundary.
If the operation can be triggered from several interfaces, centralize the authorization and domain operation but keep transport checks at each boundary. An admin form verifies its nonce, a REST endpoint uses its authentication mechanism and permission callback, and WP-CLI relies on an authorized shell context. Sharing the write function is useful; pretending that every transport establishes trust in the same way is not.
Remember the two-question rule
Every state-changing WordPress request should answer two separate questions: “Did this request originate from the interface we issued?” and “May this user perform this action on this resource?” Nonces answer the first; capabilities answer the second. Validation governs what may enter the system, while escaping governs how stored data leaves it. Keep all four visible in the handler, and reviewers can assess the form without guessing. WordPress’s nonce documentation and capability guidance provide the underlying API details.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


