Relying solely on wp_rest_nonce for REST API security creates a false sense of safety. Nonces protect against Cross-Site Request Forgery (CSRF) within a browser session but provide zero authentication for server-to-server or headless requests. This protocol defines the mandatory security layers for exposing custom data via register_rest_route: rigorous authentication differentiation, capability-based permission callbacks, and strict input validation.


1. Authentication Contexts: Browser vs. Headless

Developers often conflate “Authorization” (can you do this?) with “Authentication” (who are you?). Your API must handle two distinct contexts:

ContextAuth MechanismPrimary ThreatMitigation
Browser (JS frontend)Cookie AuthenticationCSRF (Forged requests)X-WP-Nonce header required. WordPress handles cookie validation automatically.
Headless / ServerApplication Passwords / JWTSpoofing / ReplayNever use nonces here. Use Authorization: Basic <token> or Bearer <jwt>.

Critical Rule: A valid Nonce $\neq$ A valid User. A nonce only proves intent from a browser; it does not prove identity for an external app.


2. The Golden Rule: Permission Callbacks

Since WordPress 5.5, permission_callback is mandatory. Returning __return_true is a CWE-285 violation (Improper Authorization) unless the endpoint is explicitly public.

Code Snippet: Strict Capability Check

php
<?php

add_action('rest_api_init', function () {
register_rest_route('my-secure-plugin/v1', '/private-data', [
'methods' => 'POST',
'callback' => 'handle_private_data_request',
// 🛡️ SECURITY LAYER 1: Permission Callback
'permission_callback' => function (\WP_REST_Request $request) {

// 1. Check if user is logged in (Authentication)
if (!is_user_logged_in()) {
return new \WP_Error(
'rest_forbidden',
__('You must be logged in to access this resource.', 'text-domain'),
['status' => 401]
);
}

// 2. Check specific capability (Authorization)
// NEVER use 'manage_options' lazy check. Be granular.
if (!current_user_can('edit_others_posts')) {
return new \WP_Error(
'rest_forbidden',
__('Insufficient permissions.', 'text-domain'),
['status' => 403]
);
}

// 3. (Optional) Check specific object ownership if applicable
$post_id = $request->get_param('post_id');
if ($post_id && !current_user_can('edit_post', $post_id)) {
return false; // Returns generic 403
}

return true;
},
// 🛡️ SECURITY LAYER 2: Input Validation (See Section 3)
'args' => get_secure_endpoint_args(),
]);
});

3. Input Hygiene: Validate vs. Sanitize

Never trust $_POST or $request->get_param(). Define strict schemas in the args array.

  • validate_callback: Rejects invalid data (Returns 400 Bad Request).
  • sanitize_callback: Cleans data before your callback sees it.

Code Snippet: Validation Schema

php
function get_secure_endpoint_args() {
return [
'api_key' => [
'required' => true,
'type' => 'string',
'validate_callback' => function($param, $request, $key) {
// Reject if not exactly 32 alphanumeric chars
return preg_match('/^[a-zA-Z0-9]{32}$/', $param);
},
'sanitize_callback' => 'sanitize_text_field',
],
'target_email' => [
'required' => true,
'validate_callback' => 'is_email',
'sanitize_callback' => 'sanitize_email',
],
'limit' => [
'default' => 10,
'validate_callback' => function($param) {
return is_numeric($param) && $param > 0 && $param <= 50;
},
'sanitize_callback' => 'absint', // Enforce integer type
]
];
}

4. Information Disclosure Protocol

⚠️ Warning: The “User ID” Enumeration Trap

Do not accept user_id as an input argument for sensitive actions unless you strictly verify the current user is that ID (or is an admin).

Vulnerable:
GET /v1/orders?user_id=123 -> Attacker changes 123 to 124 to see others’ orders.

Secure:
Ignore the user_id parameter entirely. Always infer context from the authenticated session:

php
$current_user_id = get_current_user_id(); // Trust this, not the param

⚠️ Warning: Error Verbosity

Default WordPress errors are verbose. Do not expose internal logic in production.

Bad Error:
"Database query failed in /var/www/html/..."

Secure Error:

php
return new \WP_REST_Response([
'code' => 'internal_server_error',
'message' => 'An unexpected error occurred. Reference ID: ' . $log_id
], 500);

Checklist for Deployment

  1.  Endpoint Scope: Is permission_callback defined and returning strictly boolean/WP_Error?
  2.  Auth Check: Does it use current_user_can() instead of just is_user_logged_in()?
  3.  Input Type: Are all args typed, validated, and sanitized?
  4.  Response Hygiene: Are stack traces suppressed in REST responses?
  5.  Rate Limiting: Is the endpoint protected against brute force (e.g., via WAF or object cache limiting)?

Implementation of this protocol is mandatory for all custom API routes to prevent Privilege Escalation (IDOR) and Injection attacks.