WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Securing Custom REST API Endpoints with Nonces and Capability Checks
Advanced WordPress Topic
October 18, 2026

Securing Custom REST API Endpoints with Nonces and Capability Checks

Why a permission_callback Is Not Optional

Every route you register with register_rest_route() is publicly reachable at /wp-json/your-namespace/v1/your-route. WordPress will happily dispatch the request to your callback before you have had any chance to check who is asking. If you skip permission_callback, WordPress fires a _doing_it_wrong() notice and — more importantly — your endpoint becomes an open door. Anyone can POST to it, delete records, or trigger expensive queries.

This tutorial builds a small but realistic endpoint that lets a logged-in editor toggle a “featured” flag on a post. We will register it with a strict permission_callback, verify a nonce for cookie-authenticated requests, sanitise every argument, and return proper WP_Error objects so the client can react sensibly. The same skeleton works for any custom route you write.

Prerequisites

  • WordPress 6.5 or newer (block bindings are stable from here).
  • A child theme or a small custom plugin where you can add PHP. A plugin is preferable — REST routes should not vanish when a theme is switched.
  • Basic familiarity with add_action( 'rest_api_init', ... ).
  • Access to your browser dev tools so you can inspect the nonce header.

Step 1 — Register the Route with a Real permission_callback

The permission_callback runs before your main callback and before argument validation for the request body. It receives the full WP_REST_Request object, so you can inspect headers, parameters, and the current user. Return true to allow, or a WP_Error to deny.

<?php
add_action( 'rest_api_init', function () {
    register_rest_route(
        'wptutshub/v1',
        '/posts/(?P<id>\d+)/featured',
        array(
            'methods'             => WP_REST_Server::EDITABLE, // POST, PUT, PATCH
            'callback'            => 'wptutshub_toggle_featured',
            'permission_callback' => 'wptutshub_can_toggle_featured',
            'args'                => array(
                'id' => array(
                    'required'          => true,
                    'validate_callback' => static function ( $value ) {
                        return is_numeric( $value ) && (int) $value > 0;
                    },
                    'sanitize_callback' => 'absint',
                ),
                'featured' => array(
                    'required'          => true,
                    'type'              => 'boolean',
                    'sanitize_callback' => 'rest_sanitize_boolean',
                ),
            ),
        )
    );
} );

Two things to notice. First, WP_REST_Server::EDITABLE restricts the route to state-changing verbs — a GET request will be rejected with a 405 before your code runs. Second, the args array does double duty: validate_callback rejects malformed input, and sanitize_callback coerces the value into the type your callback expects. Never trust $request['featured'] to be a real boolean just because the client sent true.

Step 2 — Combine Capability Checks with Nonce Verification

Capability checks answer “is this user allowed to do this?”. Nonces answer “did this request originate from our site, and is it fresh?”. You need both. A capability check alone will happily accept a forged cross-site request from a logged-in user’s browser; a nonce alone will let a low-privilege subscriber call admin-only logic.

WordPress’s own REST API uses the X-WP-Nonce header for cookie authentication. When the nonce is valid, wp_rest is verified and wp_get_current_user() is populated. When it is missing or stale, the request is treated as anonymous — which is exactly why the capability check must come second, not first.

<?php
function wptutshub_can_toggle_featured( WP_REST_Request $request ) {
    // 1. Nonce first: proves the request came from our own front end.
    $nonce = $request->get_header( 'X-WP-Nonce' );

    if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
        return new WP_Error(
            'rest_forbidden_nonce',
            __( 'Invalid or missing security token. Reload the page and try again.', 'wptutshub' ),
            array( 'status' => 403 )
        );
    }

    // 2. Capability second: proves the user may perform this action.
    $post_id = absint( $request['id'] );

    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return new WP_Error(
            'rest_forbidden_cap',
            __( 'You are not allowed to edit this post.', 'wptutshub' ),
            array( 'status' => rest_authorization_required_code() )
        );
    }

    return true;
}

rest_authorization_required_code() returns 401 for logged-out users and 403 for logged-in users who lack the capability. That distinction matters to JavaScript clients: a 401 means “refresh the nonce and retry”, a 403 means “stop, this user cannot do this”. Use the helper rather than hard-coding 403.

Why not check_ajax_referer()?

You will see check_ajax_referer( 'wp_rest' ) in older tutorials. It works, but it calls wp_die() on failure, which produces an HTML error page inside a JSON response. Returning a WP_Error keeps the response machine-readable and lets the client branch on error.code.

Step 3 — Write the Callback and Sanitise Again

Argument sanitisation in register_rest_route() is the first line of defence, but your callback should still treat input as untrusted — especially when it derives a value (like a post ID) that then feeds into a capability check.

<?php
function wptutshub_toggle_featured( WP_REST_Request $request ) {
    $post_id  = absint( $request['id'] );
    $featured = rest_sanitize_boolean( $request['featured'] );

    $post = get_post( $post_id );

    if ( ! $post || 'post' !== $post->post_type ) {
        return new WP_Error(
            'wptutshub_not_found',
            __( 'No matching post found.', 'wptutshub' ),
            array( 'status' => 404 )
        );
    }

    $updated = update_post_meta( $post_id, '_wptutshub_featured', $featured ? '1' : '0' );

    if ( false === $updated && $featured !== (bool) get_post_meta( $post_id, '_wptutshub_featured', true ) ) {
        return new WP_Error(
            'wptutshub_update_failed',
            __( 'The featured flag could not be saved.', 'wptutshub' ),
            array( 'status' => 500 )
        );
    }

    return new WP_REST_Response(
        array(
            'id'       => $post_id,
            'featured' => $featured,
        ),
        200
    );
}

Returning a WP_REST_Response rather than a bare array lets you set the status code explicitly and add headers later if you need to. Keep the response body minimal — leaking full post objects to a client that only asked for a boolean is an unnecessary information disclosure.

Step 4 — Hand the Nonce to Your JavaScript Safely

The client needs a fresh wp_rest nonce. Do not print it into an inline <script> block with string concatenation; use wp_localize_script() or, better, wp_add_inline_script() with wp_json_encode(). Both escape correctly and both respect the script dependency graph.

<?php
add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_script(
        'wptutshub-featured',
        plugins_url( 'assets/featured.js', __FILE__ ),
        array( 'wp-api-fetch' ),
        '1.0.0',
        true
    );

    wp_localize_script(
        'wptutshub-featured',
        'wptutshubData',
        array(
            'root'  => esc_url_raw( rest_url( 'wptutshub/v1' ) ),
            'nonce' => wp_create_nonce( 'wp_rest' ),
        )
    );
} );

On the JavaScript side, prefer apiFetch from @wordpress/api-fetch. It automatically attaches the X-WP-Nonce header when you configure the middleware, and it already knows how to parse WP_Error payloads into rejected promises with a .code property.

import apiFetch from '@wordpress/api-fetch';

apiFetch.use( apiFetch.createNonceMiddleware( wptutshubData.nonce ) );

async function toggleFeatured( postId, featured ) {
    try {
        const response = await apiFetch( {
            path: `/wptutshub/v1/posts/${ postId }/featured`,
            method: 'POST',
            data: { featured },
        } );
        return response;
    } catch ( error ) {
        if ( error.code === 'rest_forbidden_nonce' ) {
            // Nonce expired: reload the page to pick up a fresh one.
            window.location.reload();
            return;
        }
        if ( error.code === 'rest_forbidden_cap' ) {
            console.warn( 'This user cannot feature posts.' );
            return;
        }
        throw error;
    }
}

Note that we never touch innerHTML. The response is a small object of primitives; render it with textContent or let React/Vue handle escaping. Building markup from a REST response and injecting it as HTML is the single most common way these endpoints get weaponised.

Step 5 — Expose the Flag Through Block Bindings

If the featured flag needs to appear on the front end, do not write a shortcode that echoes raw meta. In WordPress 6.5+ you can register a block binding source and let the editor bind a paragraph or heading to your meta key. The value is escaped by the block renderer.

<?php
add_action( 'init', function () {
    register_block_bindings_source(
        'wptutshub/featured-label',
        array(
            'label'              => __( 'Featured label', 'wptutshub' ),
            'get_value_callback' => static function ( array $source_args, $block_instance ) {
                $post_id = $block_instance->context['postId'] ?? get_the_ID();
                $flag    = get_post_meta( $post_id, '_wptutshub_featured', true );

                return $flag ? __( 'Featured', 'wptutshub' ) : '';
            },
        )
    );
} );

Because the callback returns a plain string, the block editor and the front end both escape it. You get the same data on the front end without a second REST round-trip and without any HTML injection surface.

Troubleshooting

“Sorry, you are not allowed to do that” with a valid nonce

Almost always a caching problem. Full-page caches serve a nonce generated for a different user or a different 12-hour window. Exclude pages that embed nonces from page caching, or fetch the nonce via a separate uncached AJAX call. Also check that wp_verify_nonce() is receiving the action string 'wp_rest', not the nonce itself.

401 on every request from a logged-in user

Confirm the X-WP-Nonce header is actually being sent — open dev tools, look at the request headers. If you are using fetch() directly, remember to set credentials: 'same-origin', otherwise the auth cookie is not attached and WordPress treats the request as anonymous.

Route returns 404

Flush permalinks (Settings → Permalinks → Save) if you are using pretty permalinks. Also verify the namespace and route string match exactly, including the leading slash on the route and the absence of one on the namespace.

Nonce works in the editor but not on the front end

You are probably generating the nonce in an admin-only hook. Move the wp_localize_script() call to wp_enqueue_scripts and guard the enqueue with is_user_logged_in() if the feature is members-only.

Next Steps

  • Read the official guide to adding custom endpoints and compare your args schema against the built-in ones.
  • Add rate limiting with a transient keyed on get_current_user_id() if the endpoint triggers expensive queries.
  • Log failures with error_log() or a custom table so you can spot brute-force attempts against the nonce.
  • Write a PHPUnit test using WP_Test_REST_TestCase that asserts a subscriber receives a 403 and an editor receives a 200.
  • Review the nonce documentation for the lifetime rules and the nonce_user_logged_out filter.

Rule of thumb: if a route changes state, it needs a nonce, a capability check, and sanitised arguments — in that order. Skipping any one of the three turns a convenience endpoint into an attack surface.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook

Related

Previous Article Adding Custom Post Meta to the REST API with register_rest_field and Schema Validation
Next Article Building a RAG Search Endpoint for WordPress: Store Post Embeddings and Query Them via REST

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Auto-Generate Image Alt Text on Upload with OpenAI Vision and a Human Review Queue — photo by Sunriseforever | pixabay:10362988 Auto-Generate Image Alt Text on Upload with OpenAI Vision and a Human Review Queue
  • Building a RAG Search Endpoint for WordPress: Store Post Embeddings and Query Them via REST

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed