Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 033 / 100

Nonces in WordPress: a practical guide for developers

Stop forged requests.

Expona Admin8 min read

The Silent Threat: Why Nonces Matter

Your WordPress site is quietly vulnerable to a type of attack that happens in the background every day. A malicious actor could forge a request that looks like it came from one of your administrators, performing dangerous actions without their knowledge. Nonces in WordPress: a practical guide for developers is your defense mechanism, and understanding them is essential for anyone building plugins or custom functionality.

Nonces—a number used once—are WordPress's answer to Cross-Site Request Forgery (CSRF) attacks. Without them, an attacker could trick your admin into clicking a link on a malicious site that, unbeknownst to them, deletes posts, changes settings, or exfiltrates data. In this article, we'll walk through how nonces work, where to use them, and the common pitfalls that even experienced developers encounter.

Understanding the Problem: CSRF Without Nonces

Before diving into solutions, it's worth understanding the attack. Imagine your admin logs into WordPress on one tab, then visits a compromised forum in another tab. That forum contains hidden code that submits a form to your site:

php
<form action="https://yoursite.com/wp-admin/admin.php?action=delete_all_posts" method="POST" style="display:none;">
  <input type="submit" />
</form>
<script>document.forms[0].submit();</script>

If your site doesn't verify that the request came from a legitimate source, the action executes. The browser automatically includes cookies (the admin is logged in), so from your server's perspective, the request looks legitimate. Nonces stop this by requiring a unique, time-limited token that the attacker cannot predict or access.

wp_nonce_field in Forms: The Frontend Defense

For traditional WordPress admin and front-end forms, wp_nonce_field() is your first line of defense. This function generates a hidden input containing a nonce token that proves the form submission originated from your site.

Here's a real-world example: you've built a plugin that allows users to update their profile on the front end.

php
<form method="POST" action="">
  <label for="user_email">Email:</label>
  <input type="email" id="user_email" name="user_email" value="<?php echo esc_attr( get_user_meta( get_current_user_id(), 'user_email_custom', true ) ); ?>" required />
  
  <?php wp_nonce_field( 'update_user_profile', 'profile_nonce' ); ?>
  
  <button type="submit">Update Profile</button>
</form>

The wp_nonce_field() call outputs something like:

php
<input type="hidden" id="profile_nonce" name="profile_nonce" value="a1b2c3d4e5f6g7h8i9j0" />

On the server side, when the form is submitted, you verify the nonce before processing:

php
if ( isset( $_POST['profile_nonce'] ) ) {
  if ( ! wp_verify_nonce( $_POST['profile_nonce'], 'update_user_profile' ) ) {
    wp_die( 'Security check failed' );
  }
  
  // Safe to update the user profile
  $user_id = get_current_user_id();
  update_user_meta( $user_id, 'user_email_custom', sanitize_email( $_POST['user_email'] ) );
  wp_safe_remote_post( 'https://yoursite.com/profile-updated' );
}

The action name (first parameter) must match on both sides. The nonce token itself is generated per-session and expires after 12 or 24 hours (controlled by the wp_nonce_tick filter). An attacker, even if they can see the HTML source, cannot replicate a valid nonce for a future request.

check_admin_referer and wp_verify_nonce: Backend Validation

WordPress provides two main functions for validating nonces on the server side, and the choice depends on your context.

wp_verify_nonce: The Flexible Validator

wp_verify_nonce() returns a numeric value indicating the nonce's validity:

  • 1 if the nonce was generated in the current request cycle (most recent tick).
  • 2 if the nonce was generated in the previous tick (still valid, but older).
  • false if the nonce is invalid or expired.

This flexibility is crucial in production. WordPress nonces use a 12-hour rolling window, so a user submitting a form near the tick boundary won't be rejected. Here's a defensive pattern:

php
$nonce_result = wp_verify_nonce( $_POST['my_nonce'], 'my_action' );

if ( ! $nonce_result ) {
  wp_die( 'Invalid security token', 'nonce_failure', array( 'response' => 403 ) );
}

// Log if nonce is from previous tick (optional monitoring)
if ( 2 === $nonce_result ) {
  error_log( 'Nonce from previous tick detected' );
}

// Process the request
handle_user_submission();

check_admin_referer: The Convenience Function

check_admin_referer() is a wrapper that combines nonce verification with a wp_die() call if validation fails. It's ideal for admin pages where failure should halt execution immediately:

php
if ( isset( $_REQUEST['delete_post_nonce'] ) ) {
  check_admin_referer( 'delete_post_action', 'delete_post_nonce' );
  
  // If we reach here, the nonce is valid
  wp_delete_post( intval( $_GET['post_id'] ) );
  wp_safe_redirect( admin_url( 'edit.php' ) );
  exit;
}

Use check_admin_referer() in admin pages and settings pages where you want to fail fast. Use wp_verify_nonce() for front-end forms or when you need custom error handling.

REST API and the wp_rest Nonce

Modern WordPress development often involves the REST API. Nonces protect REST endpoints just as they protect traditional form submissions, but the mechanism differs slightly.

When you make a REST request from JavaScript (in the same domain), include the nonce in the request header:

php
// In your PHP template or plugin:
wp_localize_script( 'my-app-js', 'myAppData', array(
  'nonce' => wp_create_nonce( 'wp_rest' ),
  'apiUrl' => rest_url( '/myapp/v1/' ),
) );

Then in your JavaScript:

php
fetch( myAppData.apiUrl + 'update-setting', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-WP-Nonce': myAppData.nonce,
  },
  body: JSON.stringify( { setting: 'dark_mode', value: true } ),
} )
.then( response => response.json() )
.then( data => console.log( 'Success', data ) );

On the REST endpoint, WordPress automatically validates the nonce if you've registered the route with the default permission callback. For custom endpoints, verify manually:

php
register_rest_route(
  'myapp/v1',
  '/update-setting',
  array(
    'methods'             => 'POST',
    'callback'            => function( WP_REST_Request $request ) {
      // Verify nonce
      if ( ! wp_verify_nonce( $request->get_header( 'x_wp_nonce' ), 'wp_rest' ) ) {
        return new WP_Error( 'invalid_nonce', 'Nonce verification failed', array( 'status' => 403 ) );
      }
      
      // Update the setting
      update_option( 'my_setting_dark_mode', (bool) $request->get_param( 'value' ) );
      return array( 'success' => true );
    },
    'permission_callback' => function() {
      return current_user_can( 'manage_options' );
    },
  )
);

Note the action name is wp_rest—a standardized nonce for all REST API requests on your site. This keeps the JavaScript simple and WordPress's behavior predictable.

Critical: Nonces Are Not Authentication

This is the mistake that trips up many developers. A nonce proves that a request originated from your site, not from where the user is actually authorized to perform that action.

Always pair nonces with proper capability checks:

php
if ( isset( $_POST['publish_nonce'] ) ) {
  check_admin_referer( 'publish_action', 'publish_nonce' );
  
  // WRONG - nonce alone is not enough:
  // publish_post( $_POST['post_id'] );
  
  // CORRECT - verify the user can actually publish:
  if ( ! current_user_can( 'publish_posts' ) ) {
    wp_die( 'Insufficient permissions', 'no_permission', array( 'response' => 403 ) );
  }
  
  publish_post( intval( $_POST['post_id'] ) );
}

Nonces work hand-in-hand with authentication and authorization. WordPress handles authentication (logging in) separately through cookies or tokens. Your code must verify capabilities (authorization) independently. A nonce only confirms the request came from your domain during a valid session.

Common Mistakes

Hardcoding Nonce Names

Never use the same nonce name across your entire plugin. Generate unique action names for each form or endpoint:

php
// BAD: Used everywhere
wp_nonce_field( 'my_nonce', 'nonce' );

// GOOD: Specific to the action
wp_nonce_field( 'save_product_details', 'product_nonce' );
wp_nonce_field( 'delete_product', 'delete_nonce' );

This limits the scope of a compromised nonce.

Forgetting to Sanitize and Escape After Nonce Verification

Verifying a nonce doesn't sanitize the rest of your input. Always sanitize user data:

php
check_admin_referer( 'update_product', 'product_nonce' );

// Still need to sanitize
$product_name = sanitize_text_field( $_POST['product_name'] );
$product_price = floatval( $_POST['product_price'] );

Not Handling the Nonce on the Correct Hook

Process nonces early, before other logic. In admin, use the appropriate admin hook (like admin_init for early validation):

php
// Better: runs early
add_action( 'admin_init', function() {
  if ( isset( $_POST['my_action'] ) ) {
    check_admin_referer( 'my_action_nonce', 'nonce_field' );
    // Handle the action
  }
} );

// Avoid: running nonce checks on page-load hooks
add_action( 'wp_footer', function() {
  // Nonce checks here are fragile
} );

Mismatching Nonce Action Names

The action name must be identical when generating and verifying:

php
// In the form
wp_nonce_field( 'update_profile', 'profile_nonce' );

// On verification - MUST be identical
wp_verify_nonce( $_POST['profile_nonce'], 'update_profile' ); // ✓ Correct

// This will fail
wp_verify_nonce( $_POST['profile_nonce'], 'update_user_profile' ); // ✗ Wrong action name

Key Takeaways

  • Nonces prevent CSRF attacks by requiring a unique, unpredictable token on every state-changing request. Use wp_nonce_field() in forms and verify with wp_verify_nonce() or check_admin_referer().
  • REST API endpoints need the wp_rest nonce passed in the X-WP-Nonce header. WordPress validates this automatically for built-in endpoints; custom endpoints require manual verification.
  • Nonces do not replace authentication or authorization. Always verify user capabilities with current_user_can() in addition to nonce checks.
  • Nonces expire after two 12-hour ticks (approximately 24 hours). Design your forms to resubmit or refresh the page if a user might be idle longer than that.
  • Use unique action names for each nonce. Avoid generic names like "nonce" or "action" across multiple forms, as this widens the attack surface.
  • Sanitize and validate user input separately from nonce verification. The nonce only proves the request came from your site, not that the data is safe.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles