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
php// ❌ Insecure: anyone (even logged out) can write. register_rest_route( 'my/v1', '/settings', array( 'methods' => 'POST', 'callback' => 'my_save_settings', 'permission_callback' => '__return_true', ) );
php// ✅ 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
php// ❌ 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', ) );
php// ✅ 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
php// ❌ Insecure: raw param straight into a query/update. function my_save_settings( WP_REST_Request $request ) { update_option( 'my_color', $request['color'] ); }
php// ✅ 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
php// ❌ Insecure: any user with edit_posts can edit ANY post via the endpoint. 'permission_callback' => static fn() => current_user_can( 'edit_posts' ),
php// ✅ 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
php// ❌ Risky: stored markup flows to the DOM unescaped client-side. return array( 'title' => get_post_field( 'post_title', $id ) );
php// ✅ 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
php// ❌ 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 ); }, ) );
php// ✅ 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
php// ❌ 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 );
php// 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.


