TechEarl

WordPress REST Endpoints: Permissions, Writes and Signed Requests

Register a custom WordPress REST route with object-level permissions, Application Passwords, validated writes and an optional HMAC client with replay protection.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
How to register a custom WordPress REST API endpoint with register_rest_route on rest_api_init: namespace, methods, the required permission_callback, args validation and sanitization, and WP_REST_Response output.

Register a custom WordPress REST route on rest_api_init, declare its arguments, and authorize the specific resource in permission_callback. For a server integration, I start with WordPress Application Passwords over HTTPS; they authenticate custom routes as well as core routes.

The example below reads a post title and writes one private integration note. It restricts the post type and checks read_post or edit_post for that post. A broad read capability is not permission to inspect every private post.

A drop-in plugin with object-level permissions

Save this as wp-content/plugins/te-rest-example/te-rest-example.php and activate it. The note is deliberately plain text and this example only supports ordinary posts.

php
<?php
/**
 * Plugin Name: TE REST Example
 * Plugin URI: https://techearl.com/wordpress-custom-rest-api-endpoint
 * Description: A resource-authorized REST read and a bounded note write.
 * Version: 2.0.0
 * Author: Ishan Karunaratne
 * Author URI: https://techearl.com
 * License: GPL-2.0-or-later
 */
defined( 'ABSPATH' ) || exit;

function te_rest_post_permission( WP_REST_Request $request ) {
    $id = (int) ( $request->get_url_params()['id'] ?? 0 );
    $post = get_post( $id );
    if ( ! $post || 'post' !== $post->post_type ) {
        return new WP_Error( 'te_not_found', 'Post not found.', array( 'status' => 404 ) );
    }
    $capability = 'GET' === $request->get_method() || 'HEAD' === $request->get_method()
        ? 'read_post' : 'edit_post';
    if ( ! is_user_logged_in() || ! current_user_can( $capability, $id ) ) {
        return new WP_Error( 'te_forbidden', 'Access denied.', array(
            'status' => is_user_logged_in() ? 403 : 401,
        ) );
    }
    return true;
}

function te_rest_write_note( WP_REST_Request $request ) {
    $id = (int) ( $request->get_url_params()['id'] ?? 0 );
    $note = (string) $request->get_param( 'note' );
    // Metadata APIs unslash strings; preserve literal backslashes in the input.
    update_post_meta( $id, '_te_integration_note', wp_slash( $note ) );
    if ( ! metadata_exists( 'post', $id, '_te_integration_note' ) ||
        $note !== get_post_meta( $id, '_te_integration_note', true ) ) {
        return new WP_Error( 'te_write_failed', 'Could not verify the write.', array( 'status' => 500 ) );
    }
    return rest_ensure_response( array( 'id' => $id, 'saved' => true ) );
}

function te_rest_note_args() {
    return array(
        'id' => array(
            'required' => true,
            'validate_callback' => function ( $value ) {
                return is_scalar( $value ) && preg_match( '/^[1-9][0-9]*$/D', (string) $value );
            },
            'sanitize_callback' => 'absint',
        ),
        'note' => array(
            'required' => true,
            'validate_callback' => function ( $value ) {
                return is_string( $value ) && strlen( $value ) <= 2000;
            },
            'sanitize_callback' => 'sanitize_textarea_field',
        ),
    );
}

add_action( 'rest_api_init', function () {
    register_rest_route( 'te/v1', '/post/(?P<id>\d+)', array(
        'methods' => WP_REST_Server::READABLE,
        'permission_callback' => 'te_rest_post_permission',
        'callback' => function ( WP_REST_Request $request ) {
            $id = (int) ( $request->get_url_params()['id'] ?? 0 );
            return rest_ensure_response( array( 'id' => $id, 'title' => get_the_title( $id ) ) );
        },
        'args' => array( 'id' => te_rest_note_args()['id'] ),
    ) );
    register_rest_route( 'te/v1', '/post/(?P<id>\d+)/note', array(
        'methods' => WP_REST_Server::CREATABLE,
        'permission_callback' => 'te_rest_post_permission',
        'callback' => 'te_rest_write_note',
        'args' => te_rest_note_args(),
    ) );
} );

This is a last-write-wins note example. It does not provide a transaction across several fields or optimistic concurrency control. If multiple systems may edit the same business data, define ownership and a version/precondition contract before adapting it.

The anatomy: namespace, route, methods

te/v1 gives the API a namespace and version. The named id capture restricts path syntax; the permission callback checks what the caller may do with that resource. Query/body parameters can override a generic parameter lookup. These handlers deliberately take the resource ID from the URL parameters for both authorization and the write; the signed route also rejects query parameters and unexpected body keys.

READABLE declares GET, CREATABLE declares POST, EDITABLE covers POST/PUT/PATCH, and DELETABLE declares DELETE. WordPress can handle HEAD through a GET route. Declare only the methods your operation supports.

A public route can deliberately use __return_true, but only when its output and action are public. Omitting permission_callback produces a developer warning; adding a callback that always returns true does not secure it. Return true on success and a WP_Error or false on refusal, not a loosely typed success/error string.

Validating and sanitizing args

Validation rejects unacceptable data; sanitization transforms accepted data into the representation the handler will store. In this example a note must be a string of at most 2000 bytes, and HTML is removed because the field's contract is plain text. An empty string intentionally clears its content.

For a strict enum, declare the allowed values and validate the input. Avoid using absint() as a substitute for validation: it can turn an invalid negative number into a positive one. Return errors with an explicit HTTP status. Use rest_ensure_response() for normal response data, or WP_REST_Response when setting a custom success status or headers.

Native authenticated writes and capabilities

Create a dedicated integration user with only the capabilities it needs, then create an Application Password in that user's profile. Application Passwords authenticate the user; the route's permission callback still decides whether that user may edit the chosen post. Do not use an administrator credential merely because it is convenient.

For an interactive test, curl can prompt for the password instead of placing it literally in shell history:

bash
curl --fail-with-body --user integration-user \
  -H 'Content-Type: application/json' \
  --data '{"note":"Checked supplier data"}' \
  https://example.com/wp-json/te/v1/post/123/note

Use HTTPS and a valid certificate. Do not add --insecure, and do not follow redirects carrying credentials. Cookie-authenticated browser requests additionally need WordPress's REST nonce workflow; a nonce is not a replacement for the capability check.

Custom signed integration when justified

Use custom HMAC only when the client cannot use the native authentication contract and you can maintain its replay store, key rotation and rate limits. Signing only the body does not bind the request to a timestamp, nonce, method or route.

The optional extension below signs these exact components, separated by newline bytes:

text
METHOD
REST_ROUTE
UNIX_TIMESTAMP
NONCE
RAW_BODY

The client and server must sign identical body bytes; decoding and re-encoding JSON can change them. HTTPS remains required because HMAC does not encrypt the body. A captured valid request can still be replayed unless the server rejects reused nonces.

Add the following to the same normal plugin, before activation. Define a random secret of at least 32 bytes as TE_REST_HMAC_SECRET through private server configuration, and set TE_REST_HMAC_USER_ID to the limited integration user's ID. Do not commit the secret or send it in the request.

php
register_activation_hook( __FILE__, function () {
    global $wpdb;
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    $table = $wpdb->prefix . 'te_rest_nonces';
    $charset = $wpdb->get_charset_collate();
    dbDelta( "CREATE TABLE {$table} (
        nonce_hash char(64) NOT NULL,
        expires_at bigint(20) unsigned NOT NULL,
        PRIMARY KEY  (nonce_hash),
        KEY expires_at (expires_at)
    ) {$charset};" );
} );

function te_rest_signed_permission( WP_REST_Request $request ) {
    $deny = new WP_Error( 'te_bad_signature', 'Request refused.', array( 'status' => 401 ) );
    if ( ! is_ssl() || ! defined( 'TE_REST_HMAC_SECRET' ) ||
        ! is_string( TE_REST_HMAC_SECRET ) || strlen( TE_REST_HMAC_SECRET ) < 32 ||
        ! defined( 'TE_REST_HMAC_USER_ID' ) ) {
        return $deny;
    }
    $timestamp = (string) $request->get_header( 'x-te-timestamp' );
    $nonce = (string) $request->get_header( 'x-te-nonce' );
    $signature = (string) $request->get_header( 'x-te-signature' );
    $body = $request->get_body();
    $json = $request->get_json_params();
    if ( $request->get_query_params() || ! is_array( $json ) ||
        array_diff( array_keys( $json ), array( 'note' ) ) ) {
        return $deny;
    }
    if ( ! preg_match( '/^[0-9]{10}$/D', $timestamp ) ||
        abs( time() - (int) $timestamp ) > 300 ||
        ! preg_match( '/^[a-f0-9]{32}$/D', $nonce ) ||
        ! preg_match( '/^[a-f0-9]{64}$/D', $signature ) || strlen( $body ) > 16384 ) {
        return $deny;
    }
    $signed = $request->get_method() . "\n" . $request->get_route() . "\n" .
        $timestamp . "\n" . $nonce . "\n" . $body;
    $expected = hash_hmac( 'sha256', $signed, TE_REST_HMAC_SECRET );
    if ( ! hash_equals( $expected, $signature ) ) {
        return $deny;
    }
    $id = (int) ( $request->get_url_params()['id'] ?? 0 );
    $post = get_post( $id );
    if ( ! $post || 'post' !== $post->post_type ||
        ! user_can( (int) TE_REST_HMAC_USER_ID, 'edit_post', $id ) ) {
        return new WP_Error( 'te_forbidden', 'Access denied.', array( 'status' => 403 ) );
    }
    global $wpdb;
    $table = $wpdb->prefix . 'te_rest_nonces';
    // A unique insert makes concurrent claims race safely in the database.
    $old_suppression = $wpdb->suppress_errors( true );
    $claimed = $wpdb->insert( $table, array(
        'nonce_hash' => hash( 'sha256', $nonce ),
        'expires_at' => (int) $timestamp + 301,
    ), array( '%s', '%d' ) );
    $wpdb->suppress_errors( $old_suppression );
    if ( 1 !== $claimed ) {
        return $deny; // Duplicate nonce or storage failure: no write.
    }
    // Bounded cleanup; expired signatures are already rejected above.
    $wpdb->query( $wpdb->prepare(
        "DELETE FROM {$table} WHERE expires_at < %d LIMIT 100", time()
    ) );
    return true;
}

add_action( 'rest_api_init', function () {
    register_rest_route( 'te-signed/v1', '/post/(?P<id>\d+)/note', array(
        'methods' => WP_REST_Server::CREATABLE,
        'permission_callback' => 'te_rest_signed_permission',
        'callback' => 'te_rest_write_note',
        'args' => te_rest_note_args(),
    ) );
} );

The extension needs the table created by normal plugin activation. It fails closed if storage is unavailable. A reverse proxy must convey HTTPS correctly to WordPress through trusted server configuration; do not trust arbitrary forwarded headers inside the plugin. Restrict public request-body size and rate at the web server or edge as well as checking the body here, since application code runs after the request has arrived.

Complete signing client

This Python client reads the same secret from private environment configuration, requires an HTTPS endpoint without a query string, and refuses redirects. Set TE_REST_ENDPOINT to the full /wp-json/te-signed/v1/post/123/note URL.

python
#!/usr/bin/env python3
"""Send a signed WordPress integration note.
Author: Ishan Karunaratne - https://techearl.com/wordpress-custom-rest-api-endpoint
"""
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.parse
import urllib.request

class TE_NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

endpoint = os.environ["TE_REST_ENDPOINT"]
parsed = urllib.parse.urlsplit(endpoint)
if parsed.scheme != "https" or parsed.query or parsed.fragment or "/wp-json/" not in parsed.path:
    raise ValueError("Use an exact HTTPS wp-json endpoint with no query or fragment")
route = "/" + parsed.path.split("/wp-json/", 1)[1]
secret = os.environ["TE_REST_HMAC_SECRET"].encode("utf-8")
if len(secret) < 32:
    raise ValueError("Configure a random secret of at least 32 bytes")
body = json.dumps({"note": "Checked supplier data"}, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
message = b"\n".join([b"POST", route.encode(), timestamp.encode(), nonce.encode(), body])
signature = hmac.new(secret, message, hashlib.sha256).hexdigest()
request = urllib.request.Request(endpoint, data=body, method="POST", headers={
    "Content-Type": "application/json",
    "X-TE-Timestamp": timestamp,
    "X-TE-Nonce": nonce,
    "X-TE-Signature": signature,
})
with urllib.request.build_opener(TE_NoRedirect()).open(request, timeout=15) as response:
    print(response.status, response.read().decode("utf-8"))

If a request times out after the server claims its nonce, do not assume the write failed. Read the state through an authorized route or use a proper idempotency-key/result store for more consequential operations. A nonce store prevents replay; it is not an exactly-once delivery system.

Replay protection, rate limits and secret rotation

Do not implement one-time nonces with a separate get_transient() and set_transient() pair. Two concurrent requests can both pass the read, and transient entries can disappear before their nominal expiry. The unique database key above allows only one successful claim.

Apply a bounded request rate to these write paths using the server/edge controls available on your deployment. Decide how a trusted proxy identifies clients; arbitrary X-Forwarded-For is not a trustworthy rate-limit key. Monitor refused requests without logging authorization headers or bodies containing private data.

For this single-key example, rotate the server and client secret together during a controlled maintenance window. For overlap without downtime, design explicit key IDs and a bounded old-key lifetime. Removing a route from rest_endpoints removes it from dispatch too; hiding routes is not an authorization mechanism.

Negative tests before use

On staging, verify native calls with no credential, an invalid Application Password, a user unable to edit the target post, a nonexistent post, malformed JSON, an oversized note, a valid write, and a repeated identical write. Check database state as well as status codes.

For HMAC, also test a changed body, method, path, timestamp and nonce without resigning; an expired timestamp; sequential and concurrent reuse of one valid nonce; and unavailable nonce storage. Exactly one concurrent request should pass the nonce claim. Confirm HTTP is rejected and redirects are not followed by the client. Test the configured edge rate limit separately.

This is a small integration example, not a claim of a production security audit. For sheet-driven changes, combine it with explicit field ownership and the safe batch update workflow instead of exposing a generic arbitrary-meta writer.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressREST APIPHPregister_rest_routepermission_callback

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Hosting for WordPress agency clients by traffic tier and workload type: shared, managed WordPress, managed VPS, self-managed. Agency-side implications.

A WordPress Hosting Decision Tree for Agencies

Hosting choices for WordPress agency clients are operational decisions, not pricing decisions. The decision tree by traffic tier and workload type: shared, managed WordPress, managed VPS, self-managed VPS. Plus the agency-side implications of each.