REST API security
When to use this skill
Use this skill whenever you expose a custom REST endpoint:
- Any
register_rest_route()call. - Endpoints backing a block editor, admin SPA, or front-end fetch.
- Routes that read private data or perform writes/deletes.
- Adding fields via
register_rest_field/ extending core routes.
The REST API is reachable by anyone who can reach the site. A missing or permissive
permission_callback is broken access control — among the most common WordPress
vulnerabilities.
Core principles (and why they matter)
permission_callbackis mandatory and must be real. It is the authorization boundary.__return_truemeans "anyone, including logged-out users" — never use it for writes, and rarely for reads of non-public data. (Core warns if it's omitted entirely.)- Authorize per method and per object. A route may allow
GETpublicly but require a capability forPOST. Object routes (/items/(?P<id>\d+)) should check the user can act on that object. - Declare
argswith sanitize + validate callbacks. The REST framework processes declared inputs, but undeclared params are not automatically rejected or removed. Build write payloads from an explicit field allowlist, neverget_params()wholesale. - Don't rely on nonces for authorization. The
wp_restnonce mitigates CSRF with cookie authentication; it does not prove origin or replace capability checks. - Escape HTML in responses. JSON is not auto-safe if the client injects values into the DOM. Escape/normalize anything that may be rendered as markup.
- Use specific namespaces/versions (
my-plugin/v1) and least-privileged callbacks. - Minimize response payloads. Return the fields the client renders — never raw
objects, rows, or user records. Authorize both the records and the fields exposed;
minimization cannot replace permission checks. Core's
?_fields=parameter lets clients trim further; it complements, not replaces, a small default payload.
Step-by-step implementation
- Register on
rest_api_initwith a versioned namespace. - Set
permission_callbackto a function returningtrue/false/WP_Error, enforcing the right capability (and per-object check via the request args). - Define every parameter under
argswithrequired,type,sanitize_callback,validate_callback. - Use only declared, validated/sanitized params; authorize every target before writing.
- Before expensive work, enforce abuse controls and return a
WP_Errorwith status429on rejection (notwp_send_json_error()inside REST). Transient counters are non-atomic, evictable, best-effort only; strict limits need an atomic shared backend or edge/server enforcement. Seeajax-securityfor trusted proxy/keying caveats. - Perform the authorized operation; return only the permitted fields with
rest_ensure_response(), or aWP_Errorwith an HTTP status. Plain JSON text should remain text; escape at an HTML sink or normalize intentional HTML separately.
Supporting references
| Reference | Load when |
|---|---|
| REST API security checklist | Before final verification of the rest api security controls. |
| Secure REST endpoint | Implementing a versioned REST endpoint with per-object authorization, declared arguments, and explicit error responses. |
Common AI mistakes / anti-patterns
Mistake 1 — permission_callback => __return_true on a write
// ❌ Insecure: anyone (even logged out) can write.
register_rest_route( 'my/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'my_save_settings',
'permission_callback' => '__return_true',
) );
// ✅ Secure: enforce a capability.
register_rest_route( 'my/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'my_save_settings',
'permission_callback' => static function () {
return current_user_can( 'manage_options' );
},
) );
Mistake 2 — Omitting permission_callback entirely
// ❌ Insecure/broken: no callback → access control undefined (and a _doing_it_wrong notice).
register_rest_route( 'my/v1', '/data', array(
'methods' => 'GET',
'callback' => 'my_get_data',
) );
// ✅ Secure: explicit callback, even for public reads.
register_rest_route( 'my/v1', '/data', array(
'methods' => 'GET',
'callback' => 'my_get_data',
'permission_callback' => '__return_true', // intentional: this data is public
) );
Mistake 3 — Reading params without sanitize/validate
// ❌ Insecure: raw param straight into a query/update.
function my_save_settings( WP_REST_Request $request ) {
update_option( 'my_color', $request['color'] );
}
// ✅ Secure: declare args; framework sanitizes/validates before callback runs.
register_rest_route( 'my/v1', '/settings', array(
'methods' => 'POST',
'callback' => 'my_save_settings',
'permission_callback' => static fn() => current_user_can( 'manage_options' ),
'args' => array(
'color' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_hex_color',
'validate_callback' => static function ( $value ) {
return (bool) sanitize_hex_color( $value );
},
),
),
) );
function my_save_settings( WP_REST_Request $request ) {
update_option( 'my_color', $request['color'] );
return rest_ensure_response( array( 'saved' => true ) );
}
Mistake 4 — No per-object authorization
// ❌ Insecure: any user with edit_posts can edit ANY post via the endpoint.
'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
// ✅ Secure: check the specific object using the request.
'permission_callback' => static function ( WP_REST_Request $request ) {
return current_user_can( 'edit_post', absint( $request['id'] ) );
},
Mistake 5 — Returning unescaped HTML that the client renders
// ❌ Risky: stored markup flows to the DOM unescaped client-side.
return array( 'title' => get_post_field( 'post_title', $id ) );
// ✅ Safer: normalize/escape values that may be rendered as HTML.
return rest_ensure_response( array(
'title' => esc_html( get_post_field( 'post_title', $id ) ),
) );
Mistake 6 — register_rest_field() without a schema or permission check
// ❌ Insecure: writable field with no capability gate or sanitization.
register_rest_field( 'post', 'my_plugin_meta', array(
'get_callback' => function ( $object ) {
return get_post_meta( $object['id'], '_my_plugin_meta', true );
},
'update_callback' => function ( $value, $object ) {
update_post_meta( $object->ID, '_my_plugin_meta', $value );
},
) );
// ✅ Secure: schema with sanitize/validate + capability check on update.
register_rest_field(
'post',
'my_plugin_meta',
array(
'get_callback' => function ( $object ) {
return get_post_meta( $object['id'], '_my_plugin_meta', true );
},
'update_callback' => function ( $value, $object ) {
if ( ! current_user_can( 'edit_post', $object->ID ) ) {
return new WP_Error( 'forbidden', __( 'Forbidden.', 'my-plugin' ), array( 'status' => 403 ) );
}
update_post_meta( $object->ID, '_my_plugin_meta', sanitize_text_field( $value ) );
return true;
},
'schema' => array(
'type' => 'string',
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);
Mistake 7 — Forgetting batch endpoint authentication
WordPress supports /wp-json/batch/v1 requests that invoke multiple routes in one HTTP
request. Each route's permission_callback is still enforced, so never rely on the batch
entry point being "internal" — every inner route must authorize itself. You can also use
the rest_authentication_errors filter to reject authentication globally when needed.
Mistake 8 — Over-exposing response data
// ❌ Over-exposed: full user records — emails, logins, roles — to any subscriber.
$data = array();
foreach ( my_plugin_get_members() as $member ) {
$data[] = $member; // WP_User: user_email, user_login, roles, all of it.
}
return rest_ensure_response( $data );
// After the permission callback and query have authorized this member set:
// return only the permitted fields the UI needs.
$data = array();
foreach ( my_plugin_get_members() as $member ) {
$data[] = array(
'id' => absint( $member->ID ),
'name' => $member->display_name, // JSON text; use textContent in the client.
);
}
return rest_ensure_response( $data );
Deciding what may leave the server at all (emails, IPs, order data) is a privacy
question — see user-data-protection-privacy.
Related: see the ajax-security skill for when to use admin-ajax instead of REST and
for the rate-limiting pattern, the http-api-ssrf-prevention skill for outbound HTTP
calls from a REST callback, and the user-data-protection-privacy skill for personal
data in responses.
Correct code examples
A complete route with permission_callback, per-object check, args
sanitize/validate, and a WP_Error failure path is in
references/secure-rest-endpoint.php.
Checklist
- Every route defines a
permission_callback(no implicit/missing one). - Writes/deletes enforce a capability;
__return_trueis used only for truly public reads. - Per-object routes check the user can act on that object (using request args).
- Authorization differs by method where appropriate (read vs write).
- All params declared under
argswithsanitize_callback+validate_callback. - Required params marked
required => true; types declared. - HTML in responses is escaped/normalized.
- Errors return
WP_Errorwith an explicit HTTP status. - Namespace is versioned and plugin-specific (
my-plugin/v1). - Responses contain only the fields the client needs — no raw objects or user records.
- Write payloads allowlist fields explicitly; undeclared REST params are never persisted wholesale.
- Abuse controls run before expensive work; strict quotas do not rely on transient counters.