Claude Skill

wp-rest-api

Scaffold and audit inbound custom WordPress REST API endpoints

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-rest-api-52f6020.zip · 11 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-rest-api
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress REST API: scaffold, review, secure

Use this skill for inbound REST endpoints. Prefer REST for new, versioned plugin APIs and external clients. Keep outbound HTTP integrations out of the route handlers, and use admin-ajax only for a concrete legacy or WP-admin-specific reason.

Read reference.md for dispatch/auth debugging, controllers, collections, register_rest_field(), and edge-case verification.

Core execution model

Apply this order when reviewing behavior:

  1. Core authentication handlers establish the current user or return an auth error.
  2. WP_REST_Server matches (namespace, route, method).
  3. Core checks required args, validates registered args, then sanitizes them.
  4. Core calls the endpoint's permission_callback.
  5. Core calls the main callback only when permission succeeds.
  6. Core converts WP_Error and other supported return values into a REST response.

Validation and sanitization therefore run before endpoint authorization. Keep their callbacks cheap, deterministic, read-only, and safe for anonymous traffic.

Review workflow

  1. Inventory all inbound REST surfaces:

    rg -n "register_rest_route|register_rest_field|rest_api_init|WP_REST_Controller" .
    
  2. Build a route matrix with namespace, path, method, callback, public/private intent, permission_callback, accepted args, and response fields.

  3. Trace every security-sensitive identifier from its exact request source into the permission check and the write/read operation. Confirm both use the same value.

  4. Trace declared and undeclared input into SQL, metadata, options, filesystem, HTTP, email, and object update calls. Reject mass assignment.

  5. Verify output field allowlists, context, pagination bounds, and stable filters.

  6. Test anonymous, low-privilege, authorized, invalid-input, not-found, and cross-object access cases. Confirm GET/HEAD are side-effect free; for writes, test method semantics and replay/retry behavior where relevant.

  7. Report each finding with severity, route/method, file and line, exploit or failure path, evidence, and the smallest correct remediation. Separate confirmed exposure from defense-in-depth advice.

Treat unauthenticated privileged writes or sensitive reads as high/critical. Treat missing object-level authorization, unbounded collections, mass assignment, and cross-source identifier confusion as security findings, not style issues.

Minimal endpoint scaffold

add_action( 'rest_api_init', static function (): void {
    register_rest_route(
        'myplugin/v1',
        '/items/(?P<id>\d+)',
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => 'myplugin_get_item',
            'permission_callback' => static function ( WP_REST_Request $request ) {
                $url_params = $request->get_url_params();
                $post_id    = (int) ( $url_params['id'] ?? 0 );

                return current_user_can( 'read_post', $post_id );
            },
            'args'                => array(
                'id' => array(
                    'required' => true,
                    'type'     => 'integer',
                    'minimum'  => 1,
                ),
            ),
        )
    );
} );

/**
 * @return WP_REST_Response|WP_Error
 */
function myplugin_get_item( WP_REST_Request $request ) {
    $url_params = $request->get_url_params();
    $post_id    = (int) ( $url_params['id'] ?? 0 );
    $post       = get_post( $post_id );

    if ( ! $post ) {
        return new WP_Error(
            'myplugin_not_found',
            __( 'Item not found.', 'myplugin' ),
            array( 'status' => 404 )
        );
    }

    return rest_ensure_response(
        array(
            'id'    => $post->ID,
            'title' => get_the_title( $post ),
        )
    );
}

The exact-source lookup is intentional. Do not replace it with $request['id'] or get_param( 'id' ) for an object identifier; merged body/query values have higher priority than the URL value.

Security and correctness rules

Require explicit permission intent

Specify permission_callback for every endpoint. Since WordPress 5.5, omitting it emits _doing_it_wrong(), but registration and dispatch continue. Missing or empty permission callbacks are skipped, so the route is open at the endpoint permission layer unless another layer or the main callback denies it.

  • Use __return_true for a deliberately public route. It is not a vulnerability by itself.

  • Never use unconditional public permission for a privileged write or sensitive read.

  • Check object-level meta capabilities with the target ID:

    current_user_can( 'edit_post', $post_id );
    current_user_can( 'edit_user', $user_id );
    
  • Return true, false, null, or WP_Error. Core denies only exact false, null, or WP_Error; falsey values such as 0, '', or array() can grant access. Prefer explicit true or a namespaced WP_Error.

  • Keep permission callbacks read-only and idempotent. Core may call them again while generating the Allow header.

  • Treat authentication and authorization separately. A valid REST nonce proves the cookie-authenticated request; it does not grant a capability.

For a public form, login, webhook, or callback route, verify the complete abuse policy: bounded input, rate/resource limits, signature or token rules where applicable, replay handling, and non-enumerating responses.

Audit public telemetry and ingestion routes as resource APIs

An analytics beacon can be intentionally public and still expose an IDOR or denial-of-service primitive. Review the complete per-request work budget, not only permission_callback.

  • Bound raw body bytes before expensive decoding where the application can do so; also enforce infrastructure/WAF limits because PHP receives the request after the web server.
  • Give every nested string/number/array a schema. Use maxLength, numeric bounds, maxItems, accepted keys, and a custom depth/node budget when core's schema cannot express it. A 1–2 MiB JSON cap is usually far too generous for a beacon that should contain a few metrics.
  • Count fan-out through hooks: dimension get-or-create queries, inserts per array element, goal evaluation, email, and outbound HTTP all belong to the anonymous request's cost. Queue slow or retriable remote delivery.
  • Do not accept a sequential record ID as proof that an anonymous client owns the record. Return an opaque random/signed token or bind the record to a server-resolved session, then update with both resource and owner predicates such as WHERE id = ? AND session_id = ?.
  • Rate-limit and quota by a proxy-safe identity, but keep storage and fan-out bounded even when attackers rotate IPs/cookies. Rate limiting is not a substitute for ownership or idempotency.
  • Return deterministic 400, 413, 422, and 429 errors. Malformed JSON or a scalar root must not fall through into PHP warnings/5xx responses.

Test cross-session record updates, replayed tokens, maximum and maximum+1 array sizes, oversized/deep bodies, concurrent first beacons, and repeated requests with outbound integrations enabled. Assert a documented upper bound on local queries/writes and zero synchronous third-party calls on the public hot path.

Declare and enforce the input contract

Declare every accepted URL, query, and body parameter in args. Undeclared parameters are not stripped and remain readable from the request, so never pass get_params() or an arbitrary JSON object directly into a model/update API.

'args' => array(
    'email' => array(
        'required'          => true,
        'type'              => 'string',
        'format'            => 'email',
        'validate_callback' => 'rest_validate_request_arg',
        'sanitize_callback' => 'sanitize_email',
    ),
    'role' => array(
        'type'    => 'string',
        'enum'    => array( 'subscriber', 'contributor', 'author' ),
        'default' => 'subscriber',
    ),
    'count' => array(
        'type'    => 'integer',
        'minimum' => 1,
        'maximum' => 100,
    ),
),

When type exists and no custom sanitize_callback is set, core defaults to rest_parse_request_arg(), which validates the registered schema and sanitizes the value. A custom sanitizer replaces that fallback. Pair it with validate_callback => rest_validate_request_arg or a custom validator, or constraints such as minimum, maximum, enum, and format may not run.

Validation proves shape; sanitization normalizes data. Neither replaces $wpdb->prepare(), capability checks, output policy, or business validation.

Read from the intended parameter source

Use source-specific accessors for identifiers and security decisions:

  • route capture: $request->get_url_params()
  • query string: $request->get_query_params()
  • JSON body: $request->get_json_params()
  • form body: $request->get_body_params()
  • uploaded files: $request->get_file_params()

get_param() and array access merge sources in this priority: JSON, form body, query string, URL, defaults. Never authorize one source and mutate another.

Return REST-native responses and errors

Return supported data or WP_REST_Response on success and WP_Error on expected failure. Prefer explicit response objects when setting status, headers, or links.

return rest_ensure_response( $data );
return new WP_REST_Response( $data, 201, array( 'Location' => $location ) );
return new WP_Error( 'myplugin_invalid', '...', array( 'status' => 422 ) );

Do not call wp_send_json_*() in REST callbacks; it terminates execution and bypasses normal REST response handling. Do not expose exception messages, stack traces, SQL, paths, secrets, or internal class names in 5xx responses.

Shape output explicitly

Do not expose unreviewed database rows, model objects, or metadata blobs. Allowlist response fields and evaluate personal/sensitive data per route and context. An email address is not safe merely because it was intentionally selected. Escape values when a client renders them into HTML; do not HTML-escape ordinary JSON data indiscriminately on the server.

Use cookie authentication correctly

Cookie-authenticated browser requests need _wpnonce or X-WP-Nonce generated for wp_rest. Without a nonce, core treats cookie auth as anonymous; an invalid nonce returns rest_cookie_invalid_nonce with 403.

When WordPress enqueues its registered wp-api-fetch script, core installs the REST nonce middleware automatically, including on the front end. A decoupled bundle importing @wordpress/api-fetch from npm must configure nonce middleware itself or use another authentication scheme. Application Passwords authenticate external HTTPS requests but still require endpoint authorization; never ship application credentials in public browser code.

Use controllers for resource APIs

For several related collection/item routes, extend WP_REST_Controller instead of duplicating registration, permission, schema, and response methods. Core provides parameter helpers, not the actual query/filter/pagination behavior. See reference.md.

Use a unique versioned namespace such as myplugin/v1; add v2 instead of breaking an existing public contract in place.

False-positive guards

  • Do not report __return_true as a vulnerability without proving the route should be private or the public operation lacks necessary abuse controls.
  • Do not treat a nonce as a substitute for capability/object authorization.
  • Do not call a missing permission_callback exploitable until tracing global filters and callback-internal checks; still report the fail-open registration pattern because tooling cannot enforce the intended policy.
  • Do not report validation errors returned before permission as an auth bypass; assess separately whether they leak sensitive schema/state or enable expensive anonymous work.
  • Do not assume 401 versus 403 inconsistency: core normally returns 401 for unauthenticated denial and 403 for an authenticated but unauthorized user.
  • Do not label an explicitly mapped database row unsafe without identifying a sensitive or unintended field. Report unreviewed broad exposure and its data.

Cross-references

  • Run wp-security-audit for the surrounding nonce, capability, input, SQL, filesystem, redirect, and output checks.
  • Run wp-client-side-media-processing for WordPress 7.1 media endpoints whose browser and server paths use different multi-request lifecycles.
  • Run wp-abilities-api when the desired contract is a discoverable typed operation rather than an HTTP resource.

Out of scope

Do not design custom JWT/OAuth/signature protocols, complete CORS/WAF/proxy policy, distributed rate limiting, or OpenAPI generation here. Do not audit core-owned wp/v2 contracts unless plugin code changes them.

References

Files (wp-agent-skills)
  • reference.md 13.6 KB
    # WordPress REST API deep reference
    
    Read this file when the main checklist is insufficient: dispatch/authentication
    debugging, controller/resource design, collections and pagination,
    `register_rest_field()`, parameter-source conflicts, or smoke testing.
    
    ## Contents
    
    - [Dispatch and permission semantics](#dispatch-and-permission-semantics)
    - [Argument schema behavior](#argument-schema-behavior)
    - [Parameter-source precedence](#parameter-source-precedence)
    - [Authentication matrix](#authentication-matrix)
    - [Controllers, collections, and pagination](#controllers-collections-and-pagination)
    - [`register_rest_field()` and response fields](#register_rest_field-and-response-fields)
    - [Errors and response contracts](#errors-and-response-contracts)
    - [Focused smoke tests](#focused-smoke-tests)
    - [Core source map](#core-source-map)
    
    ## Dispatch and permission semantics
    
    Use the following execution model for WordPress 7.1:
    
    1. `WP_REST_Server::serve_request()` calls `check_authentication()`.
    2. Authentication filters may set the current user or return `WP_Error`.
    3. `dispatch()` matches the route and endpoint method.
    4. `WP_REST_Request::has_valid_params()` checks JSON parsing, required args,
       registered validation callbacks, and any endpoint-level validator.
    5. `WP_REST_Request::sanitize_params()` sanitizes every registered parameter
       occurrence in the request sources.
    6. `respond_to_request()` calls a non-empty `permission_callback`.
    7. The main callback runs only if no prior error exists.
    8. `WP_Error` is converted and other results pass through
       `rest_ensure_response()`.
    
    `register_rest_route()` checks for the presence of `permission_callback` only
    to emit `_doing_it_wrong()`. It still registers the endpoint. During dispatch,
    an empty/missing permission callback is skipped.
    
    When a callback is present, core denies only when it returns:
    
    - exact `false`;
    - exact `null`; or
    - `WP_Error`.
    
    Other values, including `0`, `''`, and an empty array, are not denial values.
    Require callbacks to return explicit booleans or `WP_Error`.
    
    Do not mutate state in a permission callback. `rest_send_allow_header()` can
    call permission callbacks again after dispatch to determine which methods to
    advertise. A permission check must tolerate repeated execution.
    
    ## Argument schema behavior
    
    Each endpoint's `args` map applies to parameters of the same name found in URL,
    query, JSON, or form-body sources. Core does not reject unknown parameters.
    
    Use these rules:
    
    - `required => true` rejects a missing parameter unless a default supplies it.
    - `validate_callback` runs before `sanitize_callback`.
    - If `type` is present and `sanitize_callback` is absent,
      `WP_REST_Request::sanitize_params()` selects `rest_parse_request_arg()`.
    - `rest_parse_request_arg()` validates against the registered schema and then
      sanitizes against that schema.
    - A custom `sanitize_callback` disables that fallback. Add
      `validate_callback => rest_validate_request_arg` or a custom validator when
      schema constraints must still be enforced.
    - WordPress implements a subset of JSON Schema Draft 4, not arbitrary modern
      JSON Schema keywords.
    
    Keep validation pure. It executes for anonymous requests before endpoint
    permission and may execute once for each source containing the parameter.
    Perform ownership checks, remote requests, expensive queries, uniqueness
    checks, writes, and atomic reservations after authorization.
    
    ### Preparing schemas for external clients in WordPress 7.1
    
    `wp_prepare_json_schema_for_client( $schema, $profile )` prepares a
    WordPress-authored schema before it is returned to a browser, AI provider, or
    other standalone draft-04 consumer. The default profile is `draft-04`; use
    `rest-api` for the historical REST keyword subset. The helper recursively
    removes non-allowed keys, converts per-property `required: true` flags to a
    parent `required` array when appropriate, and represents an empty object default
    as a JSON object.
    
    This is an exposure/compatibility helper, not a validator. Adding a keyword via
    `wp_json_schema_allowed_keywords` does not make WordPress enforce it. Keep route
    validation within core's supported schema subset and never expose callable
    `validate_callback` or `sanitize_callback` values to clients.
    
    For object payloads, declare nested `properties` and normally set
    `additionalProperties => false` when the contract should reject unknown keys.
    Even then, copy validated allowlisted fields into the write model rather than
    mass-assigning the request.
    
    ## Parameter-source precedence
    
    `WP_REST_Request::get_param()` and array access use this default priority:
    
    1. JSON body, when the content type is JSON;
    2. form body for `POST`, `PUT`, `PATCH`, or `DELETE`;
    3. query string;
    4. URL/route captures;
    5. registered defaults.
    
    This request can therefore match `/items/7` while `get_param( 'id' )` returns
    `9` if the JSON body contains `{ "id": 9 }`.
    
    For a route identity, use:
    
    ```php
    $url_params = $request->get_url_params();
    $item_id    = (int) ( $url_params['id'] ?? 0 );
    ```
    
    For query and body contracts, use their source-specific accessors when source
    matters. If merged access is intentional, prohibit duplicate names across
    sources or test and document precedence. Ensure the permission callback and
    main callback consume the same canonical identifier.
    
    ## Authentication matrix
    
    | Client/authentication | Core behavior | Endpoint responsibility |
    |---|---|---|
    | Anonymous | Current user is normally ID 0 | Public intent or denial |
    | Logged-in browser cookie + valid REST nonce | Core authenticates the cookie user | Capability/object authorization |
    | Logged-in browser cookie, no REST nonce | Core resets current user to ID 0 for REST | Public intent or denial |
    | Cookie + invalid REST nonce | Core returns `rest_cookie_invalid_nonce` 403 | None; callback does not run |
    | Application Password over HTTPS | Core authenticates the application user | Capability/object authorization |
    | Custom OAuth/JWT/signature plugin | Plugin-specific auth filter/middleware | Verify plugin contract and authorization |
    
    The REST nonce action is `wp_rest`. Core accepts `_wpnonce` or `X-WP-Nonce` and
    returns a refreshed `X-WP-Nonce` header after successful cookie authentication.
    
    When the WordPress script registry prints `wp-api-fetch`, core adds
    `createNonceMiddleware()` and the REST root middleware. This is not the same as
    the legacy `wpApiSettings` localization used by `wp-api-request`/`wp-api`.
    External npm bundles do not automatically inherit server-generated inline data.
    
    CORS is a browser transport policy, not authentication or authorization.
    Changing `Access-Control-Allow-Origin` does not make a public endpoint private.
    
    ## Controllers, collections, and pagination
    
    Use `WP_REST_Controller` when a resource has collection, item, create, update,
    and delete operations. Override only supported methods and their permission
    checks. Keep route registration, schema, database preparation, response
    preparation, and permission methods separate.
    
    Useful base helpers include:
    
    - `get_collection_params()` for `context`, `page`, `per_page`, and `search`;
    - `get_endpoint_args_for_item_schema()` for create/update args derived from the
      item schema;
    - `prepare_response_for_collection()` for compact item data and links;
    - `filter_response_by_context()` for schema-context filtering;
    - `add_additional_fields_schema()` for registered REST fields.
    
    The base controller supplies parameter definitions, not data access. Implement:
    
    - an allowlist mapping public filter names to safe query arguments;
    - `per_page` bounds (core convention: default 10, maximum 100);
    - deterministic sorting with a unique tie-breaker to avoid duplicates/skips;
    - ownership/status visibility before returning items;
    - an explicit total-count policy;
    - `X-WP-Total` and `X-WP-TotalPages` when following core collection contracts;
    - navigation links when helpful;
    - response preparation for every item rather than raw model serialization.
    
    Do not forward arbitrary query parameters into `WP_Query`, `meta_query`,
    `tax_query`, `orderby`, SQL fragments, or custom repository filters. Validate
    sort/filter enums and cap search length. For large mutable datasets, assess
    OFFSET drift and keyset/cursor pagination instead of copying page/OFFSET
    mechanically.
    
    Counting can be more expensive than fetching one page. Do not add totals merely
    for convention when the client does not need them; document a contract change
    if omitting core-style totals.
    
    ## `register_rest_field()` and response fields
    
    `register_rest_field()` adds `get_callback`, `update_callback`, and `schema` to
    an existing REST object type. It does not accept its own
    `permission_callback`; access starts with the parent controller's route
    permission.
    
    Apply these rules:
    
    - Always provide a schema. Schema-less fields exist for backward compatibility
      but weaken discovery, context filtering, and write validation.
    - Treat schema `context` as response shaping, not authorization.
    - Do not expose a sensitive field merely because the parent post/user object is
      readable. Enforce field-specific visibility where the value is produced or
      use a better-suited registered meta/auth contract.
    - Before adding `update_callback`, verify the parent update route's capability
      is sufficient for that field. Add a field-specific capability check when it
      is not.
    - Keep callbacks free of N+1 queries. Collection responses may execute the field
      callback once per item; prime/cache data or support `_fields` effectively.
    - Return `WP_Error` from an update callback on expected failure. Core stops
      processing additional fields when it receives one.
    - Prefer `register_post_meta()`, `register_term_meta()`, or other registered meta
      with a complete `show_in_rest` schema when the value is ordinary metadata;
      use `register_rest_field()` for computed or custom-backed fields.
    
    Review `_fields`, `_embed`, and `context` behavior. They affect shape and cost,
    but do not create an authorization boundary.
    
    ## Errors and response contracts
    
    A `WP_Error` with one error becomes a JSON object with `code`, `message`, and
    `data`; the HTTP status is read from `data.status`, defaulting to 500 when no
    numeric status exists. Multiple errors add `additional_errors`.
    
    Use stable, namespaced codes that clients can branch on. Do not make clients
    parse localized message text. Keep the same code/status/shape across equivalent
    failure paths unless hiding object existence requires a deliberate 404 policy.
    
    Use common status meanings consistently:
    
    - 400: malformed or structurally invalid request;
    - 401: unauthenticated request requiring authentication;
    - 403: authenticated but not authorized, or invalid cookie nonce;
    - 404: unavailable/not found under the route's disclosure policy;
    - 409: current resource state conflicts with the operation;
    - 412: failed conditional request/precondition;
    - 422: structurally valid request with semantic field errors;
    - 429: explicit rate policy rejected the request;
    - 500: unexpected server failure without internal details.
    
    WordPress converts returned `WP_Error`; it does not provide a general exception
    contract for arbitrary callback throwables. Convert expected domain failures and
    handle unexpected exceptions without leaking internals.
    
    ## Focused smoke tests
    
    Use `WP_REST_Request` with `rest_get_server()->dispatch()` for fast in-process
    tests. Register test routes on `rest_api_init` in a test bootstrap, then assert:
    
    ```php
    $request = new WP_REST_Request( 'POST', '/myplugin/v1/items/7' );
    $request->set_url_params( array( 'id' => 7 ) );
    $request->set_query_params( array( 'id' => 8 ) );
    $request->set_body_params( array( 'id' => 9 ) );
    
    // Merged access is 9; URL-specific access is 7.
    $this->assertSame( 9, $request->get_param( 'id' ) );
    $this->assertSame( 7, $request->get_url_params()['id'] );
    ```
    
    For every private route, test at least:
    
    1. anonymous request denied;
    2. authenticated user without capability denied;
    3. authorized owner allowed;
    4. authorized user targeting another owner's object denied when required;
    5. missing/invalid required args rejected before callback;
    6. unknown fields do not reach a mass-assignment sink;
    7. repeated write behavior matches the idempotency/conflict contract;
    8. maximum `per_page`, search length, and filter enums are enforced.
    
    Also run an actual HTTP test when cookie, CORS, proxy, application password,
    file upload, or web-server behavior is relevant; in-process dispatch does not
    exercise the full transport stack.
    
    ## Core source map
    
    - `wp-includes/rest-api.php`
      - `register_rest_route()` missing-permission notice
      - `rest_cookie_check_errors()` cookie nonce behavior
      - `rest_validate_request_arg()`, `rest_sanitize_request_arg()`,
        `rest_parse_request_arg()`
      - `rest_convert_error_to_response()`
    - `wp-includes/rest-api/class-wp-rest-server.php`
      - `serve_request()`, `check_authentication()`, `dispatch()`,
        `respond_to_request()`
    - `wp-includes/rest-api/class-wp-rest-request.php`
      - parameter order, validation, sanitization, and source-specific accessors
    - `wp-includes/rest-api/endpoints/class-wp-rest-controller.php`
      - controller, collection, schema, and additional-field helpers
    - `wp-includes/capabilities.php`
      - meta capability mapping and required object IDs
    - `wp-includes/script-loader.php`
      - core `wp-api-fetch` REST root and nonce middleware setup
    
    Official references:
    
    - [Adding custom REST endpoints](https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/)
    - [Routes and endpoints](https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/)
    - [Controller classes](https://developer.wordpress.org/rest-api/extending-the-rest-api/controller-classes/)
    - [REST schema](https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/)
    - [REST authentication](https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/)
    
  • SKILL.md 14.7 KB
    ---
    name: wp-rest-api
    description: Scaffold and audit inbound custom WordPress REST API endpoints
      registered with register_rest_route on rest_api_init. Covers explicit
      permission_callback intent, public-route review, object-level authorization,
      public telemetry/beacon abuse budgets, request-source precedence,
      args/JSON Schema validation and sanitization,
      WP_REST_Controller resources, bounded pagination and filters,
      WP_REST_Response/WP_Error contracts, register_rest_field, cookie auth with
      X-WP-Nonce, and REST vs admin-ajax decisions. Use for endpoint implementation,
      security review, 401/403 debugging, headless APIs, or admin-ajax migration.
      Trigger on register_rest_route, permission_callback, WP_REST_Request,
      WP_REST_Controller, register_rest_field, rest_ensure_response, or X-WP-Nonce;
      do not trigger merely for outbound wp_remote_* integrations.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.0 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress REST API: scaffold, review, secure
    
    Use this skill for inbound REST endpoints. Prefer REST for new, versioned
    plugin APIs and external clients. Keep outbound HTTP integrations out of
    the route handlers, and use `admin-ajax` only for a concrete legacy or
    WP-admin-specific reason.
    
    Read [reference.md](reference.md) for dispatch/auth debugging, controllers,
    collections, `register_rest_field()`, and edge-case verification.
    
    ## Core execution model
    
    Apply this order when reviewing behavior:
    
    1. Core authentication handlers establish the current user or return an auth error.
    2. `WP_REST_Server` matches `(namespace, route, method)`.
    3. Core checks required args, validates registered args, then sanitizes them.
    4. Core calls the endpoint's `permission_callback`.
    5. Core calls the main `callback` only when permission succeeds.
    6. Core converts `WP_Error` and other supported return values into a REST response.
    
    Validation and sanitization therefore run before endpoint authorization. Keep
    their callbacks cheap, deterministic, read-only, and safe for anonymous traffic.
    
    ## Review workflow
    
    1. Inventory all inbound REST surfaces:
    
       ```bash
       rg -n "register_rest_route|register_rest_field|rest_api_init|WP_REST_Controller" .
       ```
    
    2. Build a route matrix with namespace, path, method, callback, public/private
       intent, `permission_callback`, accepted args, and response fields.
    3. Trace every security-sensitive identifier from its exact request source into
       the permission check and the write/read operation. Confirm both use the same
       value.
    4. Trace declared and undeclared input into SQL, metadata, options, filesystem,
       HTTP, email, and object update calls. Reject mass assignment.
    5. Verify output field allowlists, context, pagination bounds, and stable filters.
    6. Test anonymous, low-privilege, authorized, invalid-input, not-found, and
       cross-object access cases. Confirm `GET`/`HEAD` are side-effect free; for
       writes, test method semantics and replay/retry behavior where relevant.
    7. Report each finding with severity, route/method, file and line, exploit or
       failure path, evidence, and the smallest correct remediation. Separate
       confirmed exposure from defense-in-depth advice.
    
    Treat unauthenticated privileged writes or sensitive reads as high/critical.
    Treat missing object-level authorization, unbounded collections, mass assignment,
    and cross-source identifier confusion as security findings, not style issues.
    
    ## Minimal endpoint scaffold
    
    ```php
    add_action( 'rest_api_init', static function (): void {
        register_rest_route(
            'myplugin/v1',
            '/items/(?P<id>\d+)',
            array(
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => 'myplugin_get_item',
                'permission_callback' => static function ( WP_REST_Request $request ) {
                    $url_params = $request->get_url_params();
                    $post_id    = (int) ( $url_params['id'] ?? 0 );
    
                    return current_user_can( 'read_post', $post_id );
                },
                'args'                => array(
                    'id' => array(
                        'required' => true,
                        'type'     => 'integer',
                        'minimum'  => 1,
                    ),
                ),
            )
        );
    } );
    
    /**
     * @return WP_REST_Response|WP_Error
     */
    function myplugin_get_item( WP_REST_Request $request ) {
        $url_params = $request->get_url_params();
        $post_id    = (int) ( $url_params['id'] ?? 0 );
        $post       = get_post( $post_id );
    
        if ( ! $post ) {
            return new WP_Error(
                'myplugin_not_found',
                __( 'Item not found.', 'myplugin' ),
                array( 'status' => 404 )
            );
        }
    
        return rest_ensure_response(
            array(
                'id'    => $post->ID,
                'title' => get_the_title( $post ),
            )
        );
    }
    ```
    
    The exact-source lookup is intentional. Do not replace it with `$request['id']`
    or `get_param( 'id' )` for an object identifier; merged body/query values have
    higher priority than the URL value.
    
    ## Security and correctness rules
    
    ### Require explicit permission intent
    
    Specify `permission_callback` for every endpoint. Since WordPress 5.5, omitting
    it emits `_doing_it_wrong()`, but registration and dispatch continue. Missing or
    empty permission callbacks are skipped, so the route is open at the endpoint
    permission layer unless another layer or the main callback denies it.
    
    - Use `__return_true` for a deliberately public route. It is not a vulnerability
      by itself.
    - Never use unconditional public permission for a privileged write or sensitive
      read.
    - Check object-level meta capabilities with the target ID:
    
      ```php
      current_user_can( 'edit_post', $post_id );
      current_user_can( 'edit_user', $user_id );
      ```
    
    - Return `true`, `false`, `null`, or `WP_Error`. Core denies only exact `false`,
      `null`, or `WP_Error`; falsey values such as `0`, `''`, or `array()` can grant
      access. Prefer explicit `true` or a namespaced `WP_Error`.
    - Keep permission callbacks read-only and idempotent. Core may call them again
      while generating the `Allow` header.
    - Treat authentication and authorization separately. A valid REST nonce proves
      the cookie-authenticated request; it does not grant a capability.
    
    For a public form, login, webhook, or callback route, verify the complete abuse
    policy: bounded input, rate/resource limits, signature or token rules where
    applicable, replay handling, and non-enumerating responses.
    
    ### Audit public telemetry and ingestion routes as resource APIs
    
    An analytics beacon can be intentionally public and still expose an IDOR or
    denial-of-service primitive. Review the complete per-request work budget, not
    only `permission_callback`.
    
    - Bound raw body bytes before expensive decoding where the application can do
      so; also enforce infrastructure/WAF limits because PHP receives the request
      after the web server.
    - Give every nested string/number/array a schema. Use `maxLength`, numeric
      bounds, `maxItems`, accepted keys, and a custom depth/node budget when core's
      schema cannot express it. A 1–2 MiB JSON cap is usually far too generous for
      a beacon that should contain a few metrics.
    - Count fan-out through hooks: dimension get-or-create queries, inserts per
      array element, goal evaluation, email, and outbound HTTP all belong to the
      anonymous request's cost. Queue slow or retriable remote delivery.
    - Do not accept a sequential record ID as proof that an anonymous client owns
      the record. Return an opaque random/signed token or bind the record to a
      server-resolved session, then update with both resource and owner predicates
      such as `WHERE id = ? AND session_id = ?`.
    - Rate-limit and quota by a proxy-safe identity, but keep storage and fan-out
      bounded even when attackers rotate IPs/cookies. Rate limiting is not a
      substitute for ownership or idempotency.
    - Return deterministic `400`, `413`, `422`, and `429` errors. Malformed JSON or
      a scalar root must not fall through into PHP warnings/5xx responses.
    
    Test cross-session record updates, replayed tokens, maximum and maximum+1 array
    sizes, oversized/deep bodies, concurrent first beacons, and repeated requests
    with outbound integrations enabled. Assert a documented upper bound on local
    queries/writes and zero synchronous third-party calls on the public hot path.
    
    ### Declare and enforce the input contract
    
    Declare every accepted URL, query, and body parameter in `args`. Undeclared
    parameters are not stripped and remain readable from the request, so never pass
    `get_params()` or an arbitrary JSON object directly into a model/update API.
    
    ```php
    'args' => array(
        'email' => array(
            'required'          => true,
            'type'              => 'string',
            'format'            => 'email',
            'validate_callback' => 'rest_validate_request_arg',
            'sanitize_callback' => 'sanitize_email',
        ),
        'role' => array(
            'type'    => 'string',
            'enum'    => array( 'subscriber', 'contributor', 'author' ),
            'default' => 'subscriber',
        ),
        'count' => array(
            'type'    => 'integer',
            'minimum' => 1,
            'maximum' => 100,
        ),
    ),
    ```
    
    When `type` exists and no custom `sanitize_callback` is set, core defaults to
    `rest_parse_request_arg()`, which validates the registered schema and sanitizes
    the value. A custom sanitizer replaces that fallback. Pair it with
    `validate_callback => rest_validate_request_arg` or a custom validator, or
    constraints such as `minimum`, `maximum`, `enum`, and `format` may not run.
    
    Validation proves shape; sanitization normalizes data. Neither replaces
    `$wpdb->prepare()`, capability checks, output policy, or business validation.
    
    ### Read from the intended parameter source
    
    Use source-specific accessors for identifiers and security decisions:
    
    - route capture: `$request->get_url_params()`
    - query string: `$request->get_query_params()`
    - JSON body: `$request->get_json_params()`
    - form body: `$request->get_body_params()`
    - uploaded files: `$request->get_file_params()`
    
    `get_param()` and array access merge sources in this priority: JSON, form body,
    query string, URL, defaults. Never authorize one source and mutate another.
    
    ### Return REST-native responses and errors
    
    Return supported data or `WP_REST_Response` on success and `WP_Error` on
    expected failure. Prefer explicit response objects when setting status, headers,
    or links.
    
    ```php
    return rest_ensure_response( $data );
    return new WP_REST_Response( $data, 201, array( 'Location' => $location ) );
    return new WP_Error( 'myplugin_invalid', '...', array( 'status' => 422 ) );
    ```
    
    Do not call `wp_send_json_*()` in REST callbacks; it terminates execution and
    bypasses normal REST response handling. Do not expose exception messages,
    stack traces, SQL, paths, secrets, or internal class names in 5xx responses.
    
    ### Shape output explicitly
    
    Do not expose unreviewed database rows, model objects, or metadata blobs.
    Allowlist response fields and evaluate personal/sensitive data per route and
    context. An email address is not safe merely because it was intentionally
    selected. Escape values when a client renders them into HTML; do not HTML-escape
    ordinary JSON data indiscriminately on the server.
    
    ### Use cookie authentication correctly
    
    Cookie-authenticated browser requests need `_wpnonce` or `X-WP-Nonce` generated
    for `wp_rest`. Without a nonce, core treats cookie auth as anonymous; an invalid
    nonce returns `rest_cookie_invalid_nonce` with 403.
    
    When WordPress enqueues its registered `wp-api-fetch` script, core installs the
    REST nonce middleware automatically, including on the front end. A decoupled
    bundle importing `@wordpress/api-fetch` from npm must configure nonce middleware
    itself or use another authentication scheme. Application Passwords authenticate
    external HTTPS requests but still require endpoint authorization; never ship
    application credentials in public browser code.
    
    ### Use controllers for resource APIs
    
    For several related collection/item routes, extend `WP_REST_Controller` instead
    of duplicating registration, permission, schema, and response methods. Core
    provides parameter helpers, not the actual query/filter/pagination behavior.
    See [reference.md](reference.md#controllers-collections-and-pagination).
    
    Use a unique versioned namespace such as `myplugin/v1`; add `v2` instead of
    breaking an existing public contract in place.
    
    ## False-positive guards
    
    - Do not report `__return_true` as a vulnerability without proving the route
      should be private or the public operation lacks necessary abuse controls.
    - Do not treat a nonce as a substitute for capability/object authorization.
    - Do not call a missing `permission_callback` exploitable until tracing global
      filters and callback-internal checks; still report the fail-open registration
      pattern because tooling cannot enforce the intended policy.
    - Do not report validation errors returned before permission as an auth bypass;
      assess separately whether they leak sensitive schema/state or enable expensive
      anonymous work.
    - Do not assume `401` versus `403` inconsistency: core normally returns 401 for
      unauthenticated denial and 403 for an authenticated but unauthorized user.
    - Do not label an explicitly mapped database row unsafe without identifying a
      sensitive or unintended field. Report unreviewed broad exposure and its data.
    
    ## Cross-references
    
    - Run `wp-security-audit` for the surrounding nonce, capability, input, SQL,
      filesystem, redirect, and output checks.
    - Run `wp-client-side-media-processing` for WordPress 7.1 media endpoints whose
      browser and server paths use different multi-request lifecycles.
    - Run `wp-abilities-api` when the desired contract is a discoverable typed
      operation rather than an HTTP resource.
    
    ## Out of scope
    
    Do not design custom JWT/OAuth/signature protocols, complete CORS/WAF/proxy
    policy, distributed rate limiting, or OpenAPI generation here. Do not audit
    core-owned `wp/v2` contracts unless plugin code changes them.
    
    ## References
    
    - Official documentation: <https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/register_rest_route/>
    - Official documentation: <https://developer.wordpress.org/rest-api/extending-the-rest-api/controller-classes/>
    - Official documentation: <https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/>
    - Official documentation: <https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/>
    - Verified source paths:
      - `wp-includes/rest-api.php`
      - `wp-includes/rest-api/class-wp-rest-server.php`
      - `wp-includes/rest-api/class-wp-rest-request.php`
      - `wp-includes/rest-api/endpoints/class-wp-rest-controller.php`
      - `wp-includes/script-loader.php`
      - `wp-includes/capabilities.php`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related