Back to Blog
Security

WordPress REST API Security: Advanced Authentication, OAuth 2.0, and Edge Caching Patterns

David Okonkwo
41 min read

Why REST API Security Demands Focused Attention

The WordPress REST API ships enabled by default on every WordPress installation since version 4.7. That single fact changes the security surface of WordPress dramatically. Every site now exposes a structured, predictable set of HTTP endpoints that return JSON data. Bots, scrapers, and attackers know exactly where to look: /wp-json/wp/v2/posts, /wp-json/wp/v2/users, and dozens more.

For developers building headless WordPress applications, mobile app backends, or third-party integrations, the REST API is essential infrastructure. But treating it as a simple data pipe without layered security leads to leaked user data, unauthorized content modifications, and denial-of-service vulnerabilities that plugin-based WAFs rarely catch.

This article covers ten areas of REST API security in depth: authentication methods, JWT tokens, OAuth 2.0 server implementation, field-level permissions, API versioning, rate limiting, edge caching, documentation generation, and endpoint hardening. Every code example uses real WordPress functions and hooks that you can deploy in a production environment.

REST API Authentication Methods: The Full Picture

WordPress supports several authentication mechanisms for the REST API, each with different trade-offs for security, complexity, and use case fit.

Cookie Authentication

Cookie-based authentication is the default for logged-in users making requests from within the WordPress admin or front end. When a user is logged in, their browser holds a wordpress_logged_in_* cookie. REST API requests from that browser session are authenticated automatically, provided the request includes a valid nonce.

// Generating a nonce for REST API cookie auth
wp_localize_script( 'my-app-script', 'myAppData', array(
    'root'  => esc_url_raw( rest_url() ),
    'nonce' => wp_create_nonce( 'wp_rest' ),
) );

On the JavaScript side, every request must include the X-WP-Nonce header:

fetch( myAppData.root + 'wp/v2/posts', {
    headers: {
        'X-WP-Nonce': myAppData.nonce,
        'Content-Type': 'application/json',
    },
    credentials: 'same-origin',
} )
.then( response => response.json() )
.then( posts => console.log( posts ) );

Cookie authentication only works for same-origin requests. It is unsuitable for mobile apps, external integrations, or any cross-domain scenario. The nonce also expires (default lifetime is one day via the nonce_life filter), so long-running single-page applications need a strategy to refresh it.

Basic Authentication

Basic authentication transmits a username and password with every request, base64-encoded in the Authorization header. WordPress does not include basic auth support by default for the REST API, but the official Basic Auth plugin from the REST API team enables it.

This method is acceptable for local development and testing behind HTTPS. It is categorically unsuitable for production. Every request carries the full credentials, and a single compromised log file, proxy, or network tap exposes the account permanently. There is no token revocation, no scoping, and no expiry.

Application Passwords

Introduced in WordPress 5.6, Application Passwords provide a significant improvement over basic auth while maintaining a similar simplicity of use. We will cover these in depth in the next section.

OAuth 1.0a

The OAuth 1.0a plugin for WordPress has existed since the REST API’s early development. It provides token-based auth with request signing. However, the implementation is complex, the plugin has not been actively maintained, and the industry has moved decisively toward OAuth 2.0. Unless you have a specific legacy integration requirement, skip OAuth 1.0a entirely.

JWT (JSON Web Tokens)

JWT authentication uses short-lived, signed tokens that the client obtains by submitting credentials once, then includes in subsequent requests. This is the most common pattern for headless WordPress setups and mobile applications. We will build a full JWT implementation later in this article.

Application Passwords: Implementation and Limitations

Application Passwords were merged into WordPress core in version 5.6. They solve a real problem: giving external applications authenticated access without exposing the user’s primary password, while keeping the integration simple enough that non-developers can set them up.

How They Work

Each Application Password is a 24-character string (with spaces for readability) tied to a specific user account. The user generates them from the Users > Profile screen in wp-admin. Under the hood, WordPress stores a salted hash of the password in the wp_usermeta table under the _application_passwords key.

When a REST API request arrives with an Authorization: Basic header, WordPress intercepts it in wp_authenticate_application_password(). The function loops through all Application Passwords for the identified user, hashing the provided password against each stored hash with wp_check_password().

// Programmatically creating an Application Password
$user_id = 4;
$app_name = 'Mobile App Production';

$result = WP_Application_Passwords::create_new_application_password(
    $user_id,
    array(
        'name'   => $app_name,
        'app_id' => wp_generate_uuid4(),
    )
);

if ( is_wp_error( $result ) ) {
    error_log( 'Failed to create application password: ' . $result->get_error_message() );
} else {
    list( $new_password, $item ) = $result;
    // $new_password is the plaintext password - display it once
    // $item contains the stored record with uuid, created timestamp, etc.
}

Scoping and Limitations

Application Passwords inherit all capabilities of the user they belong to. There is no built-in mechanism to restrict an Application Password to specific endpoints or HTTP methods. If user ID 4 is an Administrator, any Application Password for that user has full admin access to the REST API.

This is the biggest limitation. For third-party integrations that only need read access to posts, you are forced to either create a low-privilege user specifically for that integration, or build your own scoping layer on top.

You can filter Application Password authentication to restrict scope:

add_filter( 'wp_authenticate_application_password_errors', function( $error, $user, $item, $password ) {
    // $item['uuid'] identifies which Application Password was used
    // $item['name'] is the human-readable name

    $read_only_uuids = get_option( 'wpkite_readonly_app_passwords', array() );

    if ( in_array( $item['uuid'], $read_only_uuids, true ) ) {
        if ( ! in_array( $_SERVER['REQUEST_METHOD'], array( 'GET', 'HEAD', 'OPTIONS' ), true ) ) {
            $error->add(
                'readonly_app_password',
                __( 'This application password only permits read operations.', 'wpkite' )
            );
        }
    }

    return $error;
}, 10, 4 );

Tracking and Revocation

WordPress stores the last-used timestamp and last IP address for each Application Password. This data is visible in the user profile screen and accessible programmatically:

$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );

foreach ( $passwords as $pw ) {
    $name      = $pw['name'];
    $uuid      = $pw['uuid'];
    $created   = $pw['created'];
    $last_used = $pw['last_used'] ?? 'Never';
    $last_ip   = $pw['last_ip'] ?? 'Unknown';
}

Revocation is immediate. Call WP_Application_Passwords::delete_application_password( $user_id, $uuid ) and the token stops working on the next request. You can also revoke all passwords for a user with WP_Application_Passwords::delete_all_application_passwords( $user_id ).

Disabling Application Passwords

If your site does not need external API access, disable Application Passwords entirely:

add_filter( 'wp_is_application_passwords_available', '__return_false' );

For more granular control, disable them per user:

add_filter( 'wp_is_application_passwords_available_for_user', function( $available, $user ) {
    // Only allow for administrators
    if ( ! user_can( $user, 'manage_options' ) ) {
        return false;
    }
    return $available;
}, 10, 2 );

JWT Authentication: Token Generation, Refresh, and Revocation

JSON Web Tokens provide stateless authentication that is well-suited for headless architectures, mobile clients, and microservice communication. A JWT encodes user identity and claims into a signed token that the server can verify without a database lookup.

Building a Custom JWT Authentication System

Rather than relying on third-party plugins with varying maintenance schedules, here is a self-contained JWT system built with WordPress hooks and the firebase/php-jwt library.

First, install the JWT library:

composer require firebase/php-jwt

Define a secret key in wp-config.php:

define( 'WPKITE_JWT_SECRET', 'your-256-bit-secret-key-here' );
define( 'WPKITE_JWT_ISSUER', 'https://wpkite.com' );
define( 'WPKITE_JWT_EXPIRATION', 900 ); // 15 minutes
define( 'WPKITE_JWT_REFRESH_EXPIRATION', 604800 ); // 7 days

Token Generation Endpoint

Register a custom REST route that accepts credentials and returns a JWT pair:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/token', array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'wpkite_generate_jwt',
        'permission_callback' => '__return_true',
        'args'                => array(
            'username' => array(
                'required'          => true,
                'sanitize_callback' => 'sanitize_user',
            ),
            'password' => array(
                'required'          => true,
                'sanitize_callback' => 'sanitize_text_field',
            ),
        ),
    ) );

    register_rest_route( 'wpkite/v1', '/token/refresh', array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'wpkite_refresh_jwt',
        'permission_callback' => '__return_true',
    ) );

    register_rest_route( 'wpkite/v1', '/token/revoke', array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'wpkite_revoke_jwt',
        'permission_callback' => 'wpkite_jwt_permission_check',
    ) );
} );

function wpkite_generate_jwt( WP_REST_Request $request ) {
    $username = $request->get_param( 'username' );
    $password = $request->get_param( 'password' );

    $user = wp_authenticate( $username, $password );

    if ( is_wp_error( $user ) ) {
        return new WP_REST_Response( array(
            'code'    => 'invalid_credentials',
            'message' => 'The username or password you provided is incorrect.',
        ), 401 );
    }

    $access_token  = wpkite_create_access_token( $user );
    $refresh_token = wpkite_create_refresh_token( $user );

    return new WP_REST_Response( array(
        'access_token'  => $access_token,
        'refresh_token' => $refresh_token,
        'expires_in'    => WPKITE_JWT_EXPIRATION,
        'token_type'    => 'Bearer',
        'user_id'       => $user->ID,
        'user_email'    => $user->user_email,
        'display_name'  => $user->display_name,
    ), 200 );
}

Token Creation Functions

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function wpkite_create_access_token( WP_User $user ) {
    $issued_at = time();
    $payload   = array(
        'iss'  => WPKITE_JWT_ISSUER,
        'iat'  => $issued_at,
        'nbf'  => $issued_at,
        'exp'  => $issued_at + WPKITE_JWT_EXPIRATION,
        'sub'  => $user->ID,
        'type' => 'access',
        'caps' => array_keys( array_filter( $user->allcaps ) ),
    );

    return JWT::encode( $payload, WPKITE_JWT_SECRET, 'HS256' );
}

function wpkite_create_refresh_token( WP_User $user ) {
    $issued_at = time();
    $jti       = wp_generate_uuid4();

    // Store the refresh token ID so we can revoke it later
    $stored_tokens   = get_user_meta( $user->ID, '_wpkite_refresh_tokens', true );
    $stored_tokens   = is_array( $stored_tokens ) ? $stored_tokens : array();
    $stored_tokens[] = array(
        'jti'        => $jti,
        'issued_at'  => $issued_at,
        'expires_at' => $issued_at + WPKITE_JWT_REFRESH_EXPIRATION,
    );

    // Clean expired tokens while we are here
    $stored_tokens = array_filter( $stored_tokens, function( $token ) {
        return $token['expires_at'] > time();
    } );

    update_user_meta( $user->ID, '_wpkite_refresh_tokens', $stored_tokens );

    $payload = array(
        'iss'  => WPKITE_JWT_ISSUER,
        'iat'  => $issued_at,
        'nbf'  => $issued_at,
        'exp'  => $issued_at + WPKITE_JWT_REFRESH_EXPIRATION,
        'sub'  => $user->ID,
        'jti'  => $jti,
        'type' => 'refresh',
    );

    return JWT::encode( $payload, WPKITE_JWT_SECRET, 'HS256' );
}

Token Refresh Logic

The refresh endpoint validates the refresh token and issues a new access/refresh pair. This rotation pattern means a stolen refresh token can only be used once before the legitimate client detects the theft (its refresh token stops working).

function wpkite_refresh_jwt( WP_REST_Request $request ) {
    $refresh_token = $request->get_param( 'refresh_token' );

    if ( empty( $refresh_token ) ) {
        return new WP_REST_Response( array(
            'code'    => 'missing_token',
            'message' => 'Refresh token is required.',
        ), 400 );
    }

    try {
        $decoded = JWT::decode( $refresh_token, new Key( WPKITE_JWT_SECRET, 'HS256' ) );
    } catch ( \Exception $e ) {
        return new WP_REST_Response( array(
            'code'    => 'invalid_token',
            'message' => 'The refresh token is invalid or has expired.',
        ), 401 );
    }

    if ( 'refresh' !== $decoded->type ) {
        return new WP_REST_Response( array(
            'code'    => 'wrong_token_type',
            'message' => 'An access token cannot be used for refresh.',
        ), 400 );
    }

    // Verify the JTI has not been revoked
    $stored_tokens = get_user_meta( $decoded->sub, '_wpkite_refresh_tokens', true );
    $stored_tokens = is_array( $stored_tokens ) ? $stored_tokens : array();

    $token_valid = false;
    $remaining   = array();

    foreach ( $stored_tokens as $stored ) {
        if ( $stored['jti'] === $decoded->jti ) {
            $token_valid = true;
            // Remove the used token (rotation)
            continue;
        }
        $remaining[] = $stored;
    }

    if ( ! $token_valid ) {
        // Possible token reuse attack: revoke ALL refresh tokens for this user
        delete_user_meta( $decoded->sub, '_wpkite_refresh_tokens' );

        return new WP_REST_Response( array(
            'code'    => 'token_reuse_detected',
            'message' => 'This refresh token has already been used. All sessions have been revoked for security.',
        ), 401 );
    }

    update_user_meta( $decoded->sub, '_wpkite_refresh_tokens', $remaining );

    $user = get_user_by( 'ID', $decoded->sub );

    if ( ! $user ) {
        return new WP_REST_Response( array(
            'code'    => 'user_not_found',
            'message' => 'The user associated with this token no longer exists.',
        ), 404 );
    }

    return new WP_REST_Response( array(
        'access_token'  => wpkite_create_access_token( $user ),
        'refresh_token' => wpkite_create_refresh_token( $user ),
        'expires_in'    => WPKITE_JWT_EXPIRATION,
        'token_type'    => 'Bearer',
    ), 200 );
}

Authenticating Requests with the JWT

Hook into determine_current_user to read the Bearer token and set the current user:

add_filter( 'determine_current_user', 'wpkite_jwt_authenticate', 20 );

function wpkite_jwt_authenticate( $user_id ) {
    // Do not override if already authenticated (cookie auth, etc.)
    if ( $user_id ) {
        return $user_id;
    }

    $auth_header = isset( $_SERVER['HTTP_AUTHORIZATION'] )
        ? $_SERVER['HTTP_AUTHORIZATION']
        : ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] )
            ? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
            : '' );

    if ( empty( $auth_header ) || 0 !== strpos( $auth_header, 'Bearer ' ) ) {
        return $user_id;
    }

    $token = trim( substr( $auth_header, 7 ) );

    try {
        $decoded = JWT::decode( $token, new Key( WPKITE_JWT_SECRET, 'HS256' ) );
    } catch ( \Firebase\JWT\ExpiredException $e ) {
        // Token has expired; the client should use the refresh endpoint
        return $user_id;
    } catch ( \Exception $e ) {
        return $user_id;
    }

    if ( 'access' !== $decoded->type ) {
        return $user_id;
    }

    $user = get_user_by( 'ID', $decoded->sub );

    if ( $user && ! is_wp_error( $user ) ) {
        return $user->ID;
    }

    return $user_id;
}

Token Revocation

The revocation endpoint invalidates all refresh tokens for the authenticated user, effectively logging out all API sessions:

function wpkite_revoke_jwt( WP_REST_Request $request ) {
    $user_id = get_current_user_id();

    delete_user_meta( $user_id, '_wpkite_refresh_tokens' );

    return new WP_REST_Response( array(
        'message' => 'All refresh tokens have been revoked.',
    ), 200 );
}

function wpkite_jwt_permission_check() {
    return is_user_logged_in();
}

Access tokens remain valid until they expire (15 minutes in our configuration), which is why keeping the expiration short matters. For immediate invalidation, you would need to maintain a blocklist of revoked token IDs checked on every request, which adds a database query and partially negates the stateless benefit of JWTs.

Building an OAuth 2.0 Server for WordPress

OAuth 2.0 is the industry standard for delegated authorization. Instead of sharing passwords, a user grants a third-party application limited access through a controlled authorization flow. This section builds a functional OAuth 2.0 Authorization Code flow on top of WordPress.

Database Schema for OAuth

OAuth requires persistent storage for clients, authorization codes, and access tokens:

function wpkite_create_oauth_tables() {
    global $wpdb;
    $charset_collate = $wpdb->get_charset_collate();

    $sql = array();

    $sql[] = "CREATE TABLE {$wpdb->prefix}wpkite_oauth_clients (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        client_id varchar(80) NOT NULL,
        client_secret varchar(255) NOT NULL,
        client_name varchar(255) NOT NULL,
        redirect_uri text NOT NULL,
        grant_types varchar(80) DEFAULT 'authorization_code',
        scope text DEFAULT NULL,
        user_id bigint(20) unsigned DEFAULT NULL,
        created_at datetime DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        UNIQUE KEY client_id (client_id)
    ) $charset_collate;";

    $sql[] = "CREATE TABLE {$wpdb->prefix}wpkite_oauth_authorization_codes (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        authorization_code varchar(255) NOT NULL,
        client_id varchar(80) NOT NULL,
        user_id bigint(20) unsigned NOT NULL,
        redirect_uri text NOT NULL,
        scope text DEFAULT NULL,
        expires_at datetime NOT NULL,
        code_challenge varchar(128) DEFAULT NULL,
        code_challenge_method varchar(10) DEFAULT NULL,
        created_at datetime DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        UNIQUE KEY authorization_code (authorization_code)
    ) $charset_collate;";

    $sql[] = "CREATE TABLE {$wpdb->prefix}wpkite_oauth_access_tokens (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        access_token varchar(255) NOT NULL,
        client_id varchar(80) NOT NULL,
        user_id bigint(20) unsigned NOT NULL,
        scope text DEFAULT NULL,
        expires_at datetime NOT NULL,
        created_at datetime DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        UNIQUE KEY access_token (access_token),
        KEY user_id (user_id),
        KEY expires_at (expires_at)
    ) $charset_collate;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    foreach ( $sql as $query ) {
        dbDelta( $query );
    }
}
register_activation_hook( __FILE__, 'wpkite_create_oauth_tables' );

Client Registration

function wpkite_register_oauth_client( $name, $redirect_uri, $user_id = 0 ) {
    global $wpdb;

    $client_id     = wp_generate_password( 32, false );
    $client_secret = wp_generate_password( 64, false );

    $wpdb->insert(
        $wpdb->prefix . 'wpkite_oauth_clients',
        array(
            'client_id'     => $client_id,
            'client_secret' => wp_hash_password( $client_secret ),
            'client_name'   => sanitize_text_field( $name ),
            'redirect_uri'  => esc_url_raw( $redirect_uri ),
            'user_id'       => absint( $user_id ),
        ),
        array( '%s', '%s', '%s', '%s', '%d' )
    );

    return array(
        'client_id'     => $client_id,
        'client_secret' => $client_secret,
    );
}

Authorization Endpoint

The authorization endpoint is a WordPress page template that prompts the logged-in user to approve or deny the client’s request:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/oauth/authorize', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'wpkite_oauth_authorize',
        'permission_callback' => function() {
            return is_user_logged_in();
        },
        'args'                => array(
            'response_type'         => array( 'required' => true ),
            'client_id'             => array( 'required' => true ),
            'redirect_uri'          => array( 'required' => true ),
            'state'                 => array( 'required' => true ),
            'scope'                 => array( 'required' => false ),
            'code_challenge'        => array( 'required' => false ),
            'code_challenge_method' => array( 'required' => false ),
        ),
    ) );
} );

function wpkite_oauth_authorize( WP_REST_Request $request ) {
    global $wpdb;

    $client_id    = sanitize_text_field( $request->get_param( 'client_id' ) );
    $redirect_uri = esc_url_raw( $request->get_param( 'redirect_uri' ) );
    $state        = sanitize_text_field( $request->get_param( 'state' ) );
    $scope        = sanitize_text_field( $request->get_param( 'scope' ) );

    // Validate client
    $client = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}wpkite_oauth_clients WHERE client_id = %s",
        $client_id
    ) );

    if ( ! $client ) {
        return new WP_REST_Response( array(
            'code'    => 'invalid_client',
            'message' => 'The client identifier provided is not registered.',
        ), 400 );
    }

    // Validate redirect URI matches registered URI
    if ( trailingslashit( $redirect_uri ) !== trailingslashit( $client->redirect_uri ) ) {
        return new WP_REST_Response( array(
            'code'    => 'redirect_uri_mismatch',
            'message' => 'The redirect URI does not match the registered URI for this client.',
        ), 400 );
    }

    // Generate authorization code
    $auth_code = wp_generate_password( 40, false );
    $user_id   = get_current_user_id();

    $wpdb->insert(
        $wpdb->prefix . 'wpkite_oauth_authorization_codes',
        array(
            'authorization_code'    => wp_hash( $auth_code ),
            'client_id'             => $client_id,
            'user_id'               => $user_id,
            'redirect_uri'          => $redirect_uri,
            'scope'                 => $scope,
            'expires_at'            => gmdate( 'Y-m-d H:i:s', time() + 600 ),
            'code_challenge'        => sanitize_text_field( $request->get_param( 'code_challenge' ) ),
            'code_challenge_method' => sanitize_text_field( $request->get_param( 'code_challenge_method' ) ),
        ),
        array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' )
    );

    // Redirect back to client with the code
    $redirect = add_query_arg( array(
        'code'  => $auth_code,
        'state' => $state,
    ), $redirect_uri );

    return new WP_REST_Response( array(
        'redirect' => $redirect,
    ), 200 );
}

Token Exchange Endpoint

The client exchanges the authorization code for an access token:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/oauth/token', array(
        'methods'             => WP_REST_Server::CREATABLE,
        'callback'            => 'wpkite_oauth_token',
        'permission_callback' => '__return_true',
    ) );
} );

function wpkite_oauth_token( WP_REST_Request $request ) {
    global $wpdb;

    $grant_type    = sanitize_text_field( $request->get_param( 'grant_type' ) );
    $client_id     = sanitize_text_field( $request->get_param( 'client_id' ) );
    $client_secret = sanitize_text_field( $request->get_param( 'client_secret' ) );
    $code          = sanitize_text_field( $request->get_param( 'code' ) );
    $redirect_uri  = esc_url_raw( $request->get_param( 'redirect_uri' ) );
    $code_verifier = sanitize_text_field( $request->get_param( 'code_verifier' ) );

    if ( 'authorization_code' !== $grant_type ) {
        return new WP_REST_Response( array(
            'error'             => 'unsupported_grant_type',
            'error_description' => 'Only authorization_code grant type is supported.',
        ), 400 );
    }

    // Validate client credentials
    $client = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}wpkite_oauth_clients WHERE client_id = %s",
        $client_id
    ) );

    if ( ! $client || ! wp_check_password( $client_secret, $client->client_secret ) ) {
        return new WP_REST_Response( array(
            'error'             => 'invalid_client',
            'error_description' => 'Client authentication failed.',
        ), 401 );
    }

    // Find and validate the authorization code
    $auth_record = $wpdb->get_row( $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}wpkite_oauth_authorization_codes
         WHERE client_id = %s AND expires_at > %s
         ORDER BY created_at DESC LIMIT 1",
        $client_id,
        current_time( 'mysql', true )
    ) );

    if ( ! $auth_record || ! wp_check_password( $code, $auth_record->authorization_code ) ) {
        return new WP_REST_Response( array(
            'error'             => 'invalid_grant',
            'error_description' => 'The authorization code is invalid or has expired.',
        ), 400 );
    }

    // PKCE verification
    if ( ! empty( $auth_record->code_challenge ) ) {
        if ( empty( $code_verifier ) ) {
            return new WP_REST_Response( array(
                'error'             => 'invalid_grant',
                'error_description' => 'Code verifier is required for PKCE.',
            ), 400 );
        }

        $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' );

        if ( ! hash_equals( $auth_record->code_challenge, $expected_challenge ) ) {
            return new WP_REST_Response( array(
                'error'             => 'invalid_grant',
                'error_description' => 'PKCE verification failed.',
            ), 400 );
        }
    }

    // Delete the used authorization code
    $wpdb->delete(
        $wpdb->prefix . 'wpkite_oauth_authorization_codes',
        array( 'id' => $auth_record->id ),
        array( '%d' )
    );

    // Generate access token
    $access_token = wp_generate_password( 64, false );
    $expires_in   = 3600;

    $wpdb->insert(
        $wpdb->prefix . 'wpkite_oauth_access_tokens',
        array(
            'access_token' => wp_hash( $access_token ),
            'client_id'    => $client_id,
            'user_id'      => $auth_record->user_id,
            'scope'        => $auth_record->scope,
            'expires_at'   => gmdate( 'Y-m-d H:i:s', time() + $expires_in ),
        ),
        array( '%s', '%s', '%d', '%s', '%s' )
    );

    return new WP_REST_Response( array(
        'access_token' => $access_token,
        'token_type'   => 'Bearer',
        'expires_in'   => $expires_in,
        'scope'        => $auth_record->scope,
    ), 200 );
}

PKCE (Proof Key for Code Exchange)

The implementation above includes PKCE support, which is required for public clients (mobile apps, SPAs) that cannot securely store a client secret. The client generates a random code verifier, hashes it to create a code challenge, sends the challenge with the authorization request, and provides the original verifier when exchanging the code. This prevents authorization code interception attacks.

Field-Level Permission Callbacks and Capability Checks

Endpoint-level permission callbacks are just the first layer. For granular control, WordPress lets you attach permission logic to individual fields in the schema, controlling who can read or write specific data.

Custom Endpoint with Field-Level Permissions

register_rest_route( 'wpkite/v1', '/users/(?P<id>\d+)/profile', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'wpkite_get_user_profile',
    'permission_callback' => function( WP_REST_Request $request ) {
        // Any authenticated user can access the profile endpoint
        return is_user_logged_in();
    },
    'args'                => array(
        'id' => array(
            'validate_callback' => function( $param ) {
                return is_numeric( $param );
            },
        ),
    ),
    'schema'              => 'wpkite_user_profile_schema',
) );

function wpkite_get_user_profile( WP_REST_Request $request ) {
    $user_id   = (int) $request->get_param( 'id' );
    $user      = get_user_by( 'ID', $user_id );
    $requester = wp_get_current_user();

    if ( ! $user ) {
        return new WP_REST_Response( array(
            'code'    => 'user_not_found',
            'message' => 'No user exists with the specified ID.',
        ), 404 );
    }

    $data = array(
        'id'           => $user->ID,
        'display_name' => $user->display_name,
        'registered'   => $user->user_registered,
    );

    // Field-level permission: email is only visible to the user themselves or admins
    if ( $requester->ID === $user->ID || current_user_can( 'edit_users' ) ) {
        $data['email'] = $user->user_email;
    }

    // Field-level permission: subscription details require manage_options
    if ( current_user_can( 'manage_options' ) ) {
        $data['subscription'] = array(
            'plan'   => get_user_meta( $user_id, '_wpkite_plan', true ),
            'status' => get_user_meta( $user_id, '_wpkite_subscription_status', true ),
            'mrr'    => get_user_meta( $user_id, '_wpkite_mrr', true ),
        );
    }

    // Field-level permission: internal notes are admin-only
    if ( current_user_can( 'manage_options' ) ) {
        $data['admin_notes'] = get_user_meta( $user_id, '_wpkite_admin_notes', true );
    }

    return new WP_REST_Response( $data, 200 );
}

Modifying Core Endpoint Fields

You can also restrict fields on built-in REST endpoints. For example, removing the email field from the users endpoint for non-admins:

add_filter( 'rest_prepare_user', function( $response, $user, $request ) {
    if ( ! current_user_can( 'edit_users' ) && get_current_user_id() !== $user->ID ) {
        $data = $response->get_data();
        unset( $data['email'] );
        unset( $data['registered_date'] );
        unset( $data['capabilities'] );
        unset( $data['extra_capabilities'] );
        $response->set_data( $data );
    }
    return $response;
}, 10, 3 );

Capability-Based Permission Callbacks

Always use WordPress capabilities rather than role checks in permission callbacks. Capabilities survive role changes and work correctly with custom roles:

// Bad: checking role name
'permission_callback' => function() {
    $user = wp_get_current_user();
    return in_array( 'administrator', $user->roles, true );
}

// Good: checking capability
'permission_callback' => function() {
    return current_user_can( 'manage_options' );
}

// Better: checking specific capability relevant to the action
'permission_callback' => function( WP_REST_Request $request ) {
    $post_id = $request->get_param( 'id' );
    return current_user_can( 'edit_post', $post_id );
}

The third pattern is the most precise because edit_post accounts for post ownership, post status, and post type capabilities all at once. The function current_user_can() passes the post ID through map_meta_cap(), which translates the meta capability into primitive capabilities that are checked against the user’s actual capability set.

API Versioning Strategies

API versioning prevents breaking changes from disrupting existing clients. WordPress core uses a simple approach: the namespace includes the version (wp/v2). Your custom endpoints should follow the same pattern, but the implementation details matter.

Namespace-Based Versioning

The simplest approach: include the version number in the route namespace.

// Version 1
register_rest_route( 'wpkite/v1', '/tickets', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'wpkite_get_tickets_v1',
    'permission_callback' => 'wpkite_can_view_tickets',
) );

// Version 2 with different response structure
register_rest_route( 'wpkite/v2', '/tickets', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'wpkite_get_tickets_v2',
    'permission_callback' => 'wpkite_can_view_tickets',
) );

function wpkite_get_tickets_v1( WP_REST_Request $request ) {
    $tickets = wpkite_fetch_tickets( $request );

    // V1 returns a flat array
    return new WP_REST_Response( $tickets, 200 );
}

function wpkite_get_tickets_v2( WP_REST_Request $request ) {
    $tickets    = wpkite_fetch_tickets( $request );
    $total      = wpkite_count_tickets( $request );
    $per_page   = (int) $request->get_param( 'per_page' ) ?: 10;
    $page       = (int) $request->get_param( 'page' ) ?: 1;

    // V2 returns paginated envelope with metadata
    return new WP_REST_Response( array(
        'data'       => $tickets,
        'pagination' => array(
            'total'       => $total,
            'per_page'    => $per_page,
            'current_page'=> $page,
            'total_pages' => ceil( $total / $per_page ),
        ),
    ), 200 );
}

Header-Based Versioning

An alternative is using a custom header to specify the version, keeping URLs clean:

register_rest_route( 'wpkite/v1', '/tickets', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'wpkite_get_tickets_versioned',
    'permission_callback' => 'wpkite_can_view_tickets',
) );

function wpkite_get_tickets_versioned( WP_REST_Request $request ) {
    $version = $request->get_header( 'X-API-Version' );

    switch ( $version ) {
        case '2':
        case '2.0':
            return wpkite_get_tickets_v2( $request );
        default:
            return wpkite_get_tickets_v1( $request );
    }
}

Deprecation Headers

When retiring an old version, communicate the timeline through response headers:

add_filter( 'rest_post_dispatch', function( $response, $server, $request ) {
    $route = $request->get_route();

    if ( 0 === strpos( $route, '/wpkite/v1/' ) ) {
        $response->header( 'Deprecation', 'Sun, 01 Jan 2024 00:00:00 GMT' );
        $response->header( 'Sunset', 'Mon, 01 Jul 2024 00:00:00 GMT' );
        $response->header( 'Link', '<https://wpkite.com/api/docs/v2>; rel="successor-version"' );
    }

    return $response;
}, 10, 3 );

Rate Limiting Without Plugins

Rate limiting protects your API from abuse, brute-force attempts, and accidental loops from misbehaving clients. You can implement effective rate limiting using WordPress transients and a simple token bucket algorithm.

Transient-Based Rate Limiter

function wpkite_check_rate_limit( $identifier, $max_requests = 60, $window_seconds = 60 ) {
    $transient_key = 'wpkite_rl_' . md5( $identifier );
    $current       = get_transient( $transient_key );

    if ( false === $current ) {
        set_transient( $transient_key, array(
            'count'    => 1,
            'start'    => time(),
            'window'   => $window_seconds,
        ), $window_seconds );
        return true;
    }

    if ( $current['count'] >= $max_requests ) {
        return false;
    }

    $current['count']++;
    set_transient( $transient_key, $current, $window_seconds - ( time() - $current['start'] ) );
    return true;
}

Applying Rate Limits to REST Requests

add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) {
    // Determine the identifier: user ID for authenticated, IP for anonymous
    $identifier = is_user_logged_in()
        ? 'user_' . get_current_user_id()
        : 'ip_' . wpkite_get_client_ip();

    // Different limits for authenticated vs anonymous
    $max_requests   = is_user_logged_in() ? 120 : 30;
    $window_seconds = 60;

    if ( ! wpkite_check_rate_limit( $identifier, $max_requests, $window_seconds ) ) {
        $response = new WP_REST_Response( array(
            'code'    => 'rate_limit_exceeded',
            'message' => 'You have exceeded the maximum number of requests. Please wait before trying again.',
        ), 429 );

        $response->header( 'Retry-After', $window_seconds );
        $response->header( 'X-RateLimit-Limit', $max_requests );
        $response->header( 'X-RateLimit-Remaining', 0 );

        return $response;
    }

    return $result;
}, 10, 3 );

function wpkite_get_client_ip() {
    $headers = array(
        'HTTP_CF_CONNECTING_IP', // Cloudflare
        'HTTP_X_FORWARDED_FOR',  // General proxy
        'HTTP_X_REAL_IP',        // Nginx proxy
        'REMOTE_ADDR',           // Direct connection
    );

    foreach ( $headers as $header ) {
        if ( ! empty( $_SERVER[ $header ] ) ) {
            $ip = $_SERVER[ $header ];
            // X-Forwarded-For can contain multiple IPs; take the first
            if ( strpos( $ip, ',' ) !== false ) {
                $ip = trim( explode( ',', $ip )[0] );
            }
            if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
                return $ip;
            }
        }
    }

    return '0.0.0.0';
}

Per-Endpoint Rate Limits

Some endpoints need stricter limits. Authentication endpoints should be heavily rate-limited to prevent brute-force attacks:

add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) {
    $route = $request->get_route();

    // Strict limits on authentication endpoints
    $strict_routes = array(
        '/wpkite/v1/token',
        '/wpkite/v1/oauth/token',
    );

    if ( in_array( $route, $strict_routes, true ) ) {
        $identifier = 'auth_' . wpkite_get_client_ip();
        if ( ! wpkite_check_rate_limit( $identifier, 5, 300 ) ) {
            return new WP_REST_Response( array(
                'code'    => 'rate_limit_exceeded',
                'message' => 'Too many authentication attempts. Please wait 5 minutes.',
            ), 429 );
        }
    }

    return $result;
}, 5, 3 );

Using Object Cache for Better Performance

Transients work but hit the database on every request unless you have a persistent object cache. If your infrastructure includes Redis or Memcached, use wp_cache_get() and wp_cache_set() instead:

function wpkite_check_rate_limit_cached( $identifier, $max_requests = 60, $window = 60 ) {
    $cache_key   = 'wpkite_rl_' . md5( $identifier );
    $cache_group = 'wpkite_rate_limits';
    $count       = wp_cache_get( $cache_key, $cache_group );

    if ( false === $count ) {
        wp_cache_set( $cache_key, 1, $cache_group, $window );
        return true;
    }

    if ( (int) $count >= $max_requests ) {
        return false;
    }

    wp_cache_incr( $cache_key, 1, $cache_group );
    return true;
}

Response Caching at the Edge: Cloudflare and Varnish

REST API responses for public data (published posts, categories, site info) rarely change between requests. Caching these responses at the edge eliminates PHP execution entirely for repeated requests, reducing response times from hundreds of milliseconds to single-digit milliseconds.

Adding Cache Headers to REST Responses

The foundation is setting proper HTTP cache headers so that CDNs and reverse proxies know what to cache and for how long:

add_filter( 'rest_post_dispatch', function( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ) {
    $route  = $request->get_route();
    $method = $request->get_method();

    // Only cache GET requests
    if ( 'GET' !== $method ) {
        $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate' );
        return $response;
    }

    // Never cache authenticated responses at the edge
    if ( is_user_logged_in() ) {
        $response->header( 'Cache-Control', 'private, no-cache, must-revalidate' );
        $response->header( 'Vary', 'Authorization, Cookie' );
        return $response;
    }

    // Define cache durations per route pattern
    $cache_rules = array(
        '#^/wp/v2/posts#'      => 300,    // 5 minutes
        '#^/wp/v2/pages#'      => 600,    // 10 minutes
        '#^/wp/v2/categories#' => 3600,   // 1 hour
        '#^/wp/v2/tags#'       => 3600,   // 1 hour
        '#^/wp/v2/media#'      => 1800,   // 30 minutes
        '#^/wpkite/v1/public#' => 120,    // 2 minutes
    );

    $max_age = 0;
    foreach ( $cache_rules as $pattern => $ttl ) {
        if ( preg_match( $pattern, $route ) ) {
            $max_age = $ttl;
            break;
        }
    }

    if ( $max_age > 0 ) {
        $response->header( 'Cache-Control', sprintf(
            'public, max-age=%d, s-maxage=%d, stale-while-revalidate=%d',
            $max_age,
            $max_age * 2,
            $max_age
        ) );
        $response->header( 'Vary', 'Accept-Encoding, Origin' );

        // Add ETag for conditional requests
        $etag = md5( wp_json_encode( $response->get_data() ) );
        $response->header( 'ETag', '"' . $etag . '"' );
    } else {
        $response->header( 'Cache-Control', 'no-cache' );
    }

    return $response;
}, 10, 3 );

Cloudflare Configuration

Cloudflare does not cache API responses by default because they lack file extensions. You need a Page Rule or Cache Rule to enable caching for your REST API paths.

Using the Cloudflare API to create a cache rule programmatically:

// This would typically be a one-time setup script or WP-CLI command
function wpkite_configure_cloudflare_cache_rule() {
    $zone_id  = defined( 'CLOUDFLARE_ZONE_ID' ) ? CLOUDFLARE_ZONE_ID : '';
    $api_key  = defined( 'CLOUDFLARE_API_KEY' ) ? CLOUDFLARE_API_KEY : '';
    $email    = defined( 'CLOUDFLARE_EMAIL' ) ? CLOUDFLARE_EMAIL : '';

    if ( empty( $zone_id ) || empty( $api_key ) ) {
        return new WP_Error( 'missing_config', 'Cloudflare credentials are not configured.' );
    }

    $response = wp_remote_request(
        "https://api.cloudflare.com/client/v4/zones/{$zone_id}/pagerules",
        array(
            'method'  => 'POST',
            'headers' => array(
                'X-Auth-Email' => $email,
                'X-Auth-Key'   => $api_key,
                'Content-Type' => 'application/json',
            ),
            'body'    => wp_json_encode( array(
                'targets' => array(
                    array(
                        'target'     => 'url',
                        'constraint' => array(
                            'operator' => 'matches',
                            'value'    => '*wpkite.com/wp-json/wp/v2/*',
                        ),
                    ),
                ),
                'actions' => array(
                    array(
                        'id'    => 'cache_level',
                        'value' => 'cache_everything',
                    ),
                    array(
                        'id'    => 'edge_cache_ttl',
                        'value' => 600,
                    ),
                ),
                'priority' => 1,
                'status'   => 'active',
            ) ),
        )
    );

    return json_decode( wp_remote_retrieve_body( $response ), true );
}

Varnish VCL Configuration

For self-hosted infrastructure running Varnish, you need VCL rules that handle the REST API properly:

sub vcl_recv {
    # Cache REST API GET requests for anonymous users
    if (req.url ~ "^/wp-json/" && req.method == "GET") {
        # Do not cache if Authorization header is present
        if (req.http.Authorization) {
            return (pass);
        }

        # Do not cache if WordPress cookies are present
        if (req.http.Cookie ~ "wordpress_logged_in") {
            return (pass);
        }

        # Strip cookies for cacheable REST API requests
        unset req.http.Cookie;
        return (hash);
    }
}

sub vcl_backend_response {
    # Respect cache headers from WordPress
    if (bereq.url ~ "^/wp-json/" && beresp.http.Cache-Control ~ "public") {
        # Set TTL from s-maxage or max-age
        set beresp.ttl = std.duration(
            regsub(beresp.http.Cache-Control, ".*s-maxage=(\d+).*", "\1") + "s",
            300s
        );

        # Set grace period for stale-while-revalidate
        set beresp.grace = 60s;

        # Remove cookies from cached response
        unset beresp.http.Set-Cookie;
    }
}

sub vcl_deliver {
    # Add hit/miss header for debugging
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
        set resp.http.X-Cache-Hits = obj.hits;
    } else {
        set resp.http.X-Cache = "MISS";
    }
}

Cache Purging on Content Changes

Stale data is a serious issue. When a post is updated, the cached API response should be purged. This hook handles automatic purging for both Cloudflare and Varnish:

add_action( 'save_post', 'wpkite_purge_rest_cache', 10, 2 );
add_action( 'delete_post', 'wpkite_purge_rest_cache', 10, 2 );

function wpkite_purge_rest_cache( $post_id, $post ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    $post_type = get_post_type( $post_id );
    $rest_base = get_post_type_object( $post_type )->rest_base ?? $post_type . 's';

    $urls_to_purge = array(
        rest_url( "wp/v2/{$rest_base}" ),
        rest_url( "wp/v2/{$rest_base}/{$post_id}" ),
    );

    // Purge category and tag endpoints if applicable
    $taxonomies = get_object_taxonomies( $post_type );
    foreach ( $taxonomies as $tax ) {
        $tax_object = get_taxonomy( $tax );
        if ( $tax_object->show_in_rest ) {
            $urls_to_purge[] = rest_url( "wp/v2/{$tax_object->rest_base}" );
        }
    }

    // Cloudflare purge
    if ( defined( 'CLOUDFLARE_ZONE_ID' ) && defined( 'CLOUDFLARE_API_KEY' ) ) {
        wp_remote_request(
            'https://api.cloudflare.com/client/v4/zones/' . CLOUDFLARE_ZONE_ID . '/purge_cache',
            array(
                'method'  => 'POST',
                'headers' => array(
                    'X-Auth-Email' => CLOUDFLARE_EMAIL,
                    'X-Auth-Key'   => CLOUDFLARE_API_KEY,
                    'Content-Type' => 'application/json',
                ),
                'body'    => wp_json_encode( array( 'files' => $urls_to_purge ) ),
            )
        );
    }

    // Varnish purge via PURGE method
    foreach ( $urls_to_purge as $url ) {
        wp_remote_request( $url, array(
            'method'  => 'PURGE',
            'headers' => array(
                'Host' => wp_parse_url( home_url(), PHP_URL_HOST ),
            ),
        ) );
    }
}

API Documentation Generation with OpenAPI/Swagger

Documentation that stays synchronized with your code is documentation that people actually trust. The OpenAPI specification (formerly Swagger) provides a machine-readable format that generates interactive documentation, client libraries, and test suites.

Auto-Generating OpenAPI Specs from WordPress Routes

WordPress stores registered route information internally. You can extract it to build an OpenAPI specification:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/openapi.json', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'wpkite_generate_openapi_spec',
        'permission_callback' => '__return_true',
    ) );
} );

function wpkite_generate_openapi_spec() {
    $server = rest_get_server();
    $routes = $server->get_routes();

    $spec = array(
        'openapi' => '3.0.3',
        'info'    => array(
            'title'       => 'WPKite API',
            'description' => 'REST API for WPKite WordPress support platform.',
            'version'     => '1.0.0',
            'contact'     => array(
                'name'  => 'WPKite API Support',
                'email' => '[email protected]',
            ),
        ),
        'servers' => array(
            array( 'url' => rest_url() ),
        ),
        'paths'   => array(),
        'components' => array(
            'securitySchemes' => array(
                'bearerAuth' => array(
                    'type'         => 'http',
                    'scheme'       => 'bearer',
                    'bearerFormat' => 'JWT',
                ),
                'basicAuth' => array(
                    'type'   => 'http',
                    'scheme' => 'basic',
                ),
            ),
        ),
    );

    foreach ( $routes as $route => $handlers ) {
        // Only document our custom namespace
        if ( 0 !== strpos( $route, '/wpkite/v1' ) ) {
            continue;
        }

        $path_key = str_replace( '/wpkite/v1', '', $route );
        // Convert WordPress regex params to OpenAPI path params
        $path_key = preg_replace( '/\(\?P<(\w+)>[^)]+\)/', '{$1}', $path_key );

        if ( empty( $path_key ) ) {
            $path_key = '/';
        }

        $path_item = array();

        foreach ( $handlers as $handler ) {
            if ( ! isset( $handler['methods'] ) ) {
                continue;
            }

            $methods = array_keys( array_filter( $handler['methods'] ) );

            foreach ( $methods as $method ) {
                $method_lower = strtolower( $method );

                $operation = array(
                    'summary'    => wpkite_generate_summary( $route, $method ),
                    'parameters' => array(),
                    'responses'  => array(
                        '200' => array( 'description' => 'Successful response' ),
                        '401' => array( 'description' => 'Authentication required' ),
                        '403' => array( 'description' => 'Insufficient permissions' ),
                    ),
                );

                if ( ! empty( $handler['args'] ) ) {
                    foreach ( $handler['args'] as $arg_name => $arg_config ) {
                        $param = array(
                            'name'        => $arg_name,
                            'in'          => in_array( $method, array( 'GET', 'HEAD' ), true ) ? 'query' : 'query',
                            'required'    => ! empty( $arg_config['required'] ),
                            'schema'      => array(
                                'type' => $arg_config['type'] ?? 'string',
                            ),
                        );

                        if ( isset( $arg_config['description'] ) ) {
                            $param['description'] = $arg_config['description'];
                        }

                        $operation['parameters'][] = $param;
                    }
                }

                $path_item[ $method_lower ] = $operation;
            }
        }

        if ( ! empty( $path_item ) ) {
            $spec['paths'][ $path_key ] = $path_item;
        }
    }

    return new WP_REST_Response( $spec, 200, array(
        'Content-Type' => 'application/json',
    ) );
}

function wpkite_generate_summary( $route, $method ) {
    $parts     = explode( '/', trim( $route, '/' ) );
    $resource  = end( $parts );
    $resource  = preg_replace( '/\(\?P<\w+>[^)]+\)/', '', $resource );

    $verbs = array(
        'GET'    => 'Retrieve',
        'POST'   => 'Create',
        'PUT'    => 'Update',
        'PATCH'  => 'Partially update',
        'DELETE' => 'Delete',
    );

    $verb = $verbs[ $method ] ?? $method;
    return "{$verb} {$resource}";
}

Serving Swagger UI

Expose an interactive documentation page by loading Swagger UI from a CDN and pointing it at your OpenAPI endpoint:

add_action( 'rest_api_init', function() {
    register_rest_route( 'wpkite/v1', '/docs', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => function() {
            $spec_url = rest_url( 'wpkite/v1/openapi.json' );
            $html = '<!DOCTYPE html>
<html>
<head>
    <title>WPKite API Documentation</title>
    <link rel="stylesheet" href="https://unpkg.com/[email protected]/swagger-ui.css">
</head>
<body>
    <div id="swagger-ui"></div>
    <script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script>
    <script>
    SwaggerUIBundle({
        url: "' . esc_url( $spec_url ) . '",
        dom_id: "#swagger-ui",
        presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
    });
    </script>
</body>
</html>';

            return new WP_REST_Response( $html, 200, array(
                'Content-Type' => 'text/html',
            ) );
        },
        'permission_callback' => function() {
            // Restrict docs to authenticated users in production
            if ( 'production' === wp_get_environment_type() ) {
                return current_user_can( 'manage_options' );
            }
            return true;
        },
    ) );
} );

Security Hardening: Disabling Endpoints and CORS Configuration

The final layer of REST API security is reducing the attack surface by disabling unnecessary endpoints and configuring CORS properly.

Disabling the Users Endpoint

The /wp/v2/users endpoint exposes usernames by default, which gives attackers half of what they need for a brute-force login attempt. If your application does not need it, remove it:

add_filter( 'rest_endpoints', function( $endpoints ) {
    // Remove user enumeration endpoints
    if ( isset( $endpoints['/wp/v2/users'] ) ) {
        unset( $endpoints['/wp/v2/users'] );
    }
    if ( isset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] ) ) {
        unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
    }

    return $endpoints;
} );

For more surgical control, keep the endpoint but require authentication:

add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) {
    $route = $request->get_route();

    // Require authentication for user endpoints
    if ( preg_match( '#^/wp/v2/users#', $route ) && ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_forbidden',
            'Authentication is required to access user data.',
            array( 'status' => 401 )
        );
    }

    return $result;
}, 10, 3 );

Disabling the REST API Root Index

The root endpoint at /wp-json/ returns a full index of all registered routes, namespaces, and authentication methods. This is an information disclosure risk in production:

add_filter( 'rest_index', function( $response ) {
    if ( ! current_user_can( 'manage_options' ) ) {
        return new WP_REST_Response( array(
            'name'        => get_bloginfo( 'name' ),
            'description' => get_bloginfo( 'description' ),
            'url'         => home_url(),
            'namespaces'  => array(), // Hide route listing
            'routes'      => array(), // Hide route details
        ) );
    }
    return $response;
} );

Removing Specific Namespaces

WordPress core registers several namespaces you may not need. The block editor endpoints, for instance, are unnecessary for a headless site that does not use Gutenberg:

add_filter( 'rest_endpoints', function( $endpoints ) {
    $prefixes_to_remove = array(
        '/wp/v2/block-renderer',
        '/wp/v2/blocks',
        '/wp/v2/block-types',
        '/wp/v2/block-directory',
        '/wp/v2/block-patterns',
        '/wp/v2/pattern-directory',
        '/wp/v2/themes',
        '/wp/v2/plugins',
    );

    foreach ( $endpoints as $route => $handler ) {
        foreach ( $prefixes_to_remove as $prefix ) {
            if ( 0 === strpos( $route, $prefix ) ) {
                unset( $endpoints[ $route ] );
                break;
            }
        }
    }

    return $endpoints;
} );

CORS Configuration

Cross-Origin Resource Sharing headers control which domains can call your API from browser-based JavaScript. The default WordPress CORS handling is permissive. Tighten it:

add_action( 'rest_api_init', function() {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
    add_filter( 'rest_pre_serve_request', 'wpkite_custom_cors_headers' );
} );

function wpkite_custom_cors_headers( $served ) {
    $origin = get_http_origin();

    $allowed_origins = array(
        'https://wpkite.com',
        'https://www.wpkite.com',
        'https://app.wpkite.com',
    );

    // Allow local development origins
    if ( 'development' === wp_get_environment_type() ) {
        $allowed_origins[] = 'http://localhost:3000';
        $allowed_origins[] = 'http://localhost:5173';
        $allowed_origins[] = 'https://wpkite.com';
    }

    if ( in_array( $origin, $allowed_origins, true ) ) {
        header( 'Access-Control-Allow-Origin: ' . $origin );
        header( 'Access-Control-Allow-Credentials: true' );
        header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' );
        header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce, X-API-Version' );
        header( 'Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, X-RateLimit-Limit, X-RateLimit-Remaining' );
        header( 'Access-Control-Max-Age: 86400' );
    }

    // Handle preflight requests
    if ( 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
        status_header( 204 );
        exit;
    }

    return $served;
}

Content Security Headers for API Responses

Add security headers to all REST API responses to prevent common attacks:

add_filter( 'rest_post_dispatch', function( WP_REST_Response $response ) {
    $response->header( 'X-Content-Type-Options', 'nosniff' );
    $response->header( 'X-Frame-Options', 'DENY' );
    $response->header( 'X-XSS-Protection', '0' );
    $response->header( 'Referrer-Policy', 'strict-origin-when-cross-origin' );
    $response->header( 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()' );

    // Prevent API responses from being interpreted as HTML
    $response->header( 'Content-Type', 'application/json; charset=UTF-8' );

    return $response;
} );

Preventing User Enumeration via Other Vectors

The REST API is not the only way to enumerate users. Author archives and the login error messages also leak information:

// Block author archive scans (?author=1 redirects)
add_action( 'template_redirect', function() {
    if ( is_author() && ! is_user_logged_in() ) {
        wp_redirect( home_url(), 301 );
        exit;
    }
} );

// Prevent ?rest_route user enumeration
add_filter( 'rest_authentication_errors', function( $result ) {
    if ( ! is_user_logged_in() ) {
        $rest_route = isset( $_GET['rest_route'] ) ? $_GET['rest_route'] : '';
        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';

        if ( preg_match( '#/wp/v2/users#', $rest_route ) || preg_match( '#/wp/v2/users#', $request_uri ) ) {
            return new WP_Error(
                'rest_forbidden',
                'Authentication is required.',
                array( 'status' => 401 )
            );
        }
    }
    return $result;
} );

Logging and Monitoring API Access

Security without visibility is incomplete. Log API requests for audit and anomaly detection:

add_action( 'rest_api_init', function() {
    add_filter( 'rest_pre_dispatch', 'wpkite_log_api_request', 999, 3 );
} );

function wpkite_log_api_request( $result, $server, $request ) {
    $log_entry = array(
        'timestamp'  => current_time( 'mysql', true ),
        'method'     => $request->get_method(),
        'route'      => $request->get_route(),
        'user_id'    => get_current_user_id(),
        'ip'         => wpkite_get_client_ip(),
        'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] )
            ? sanitize_text_field( $_SERVER['HTTP_USER_AGENT'] )
            : 'unknown',
    );

    // Log to a custom table or external service
    // For high-traffic sites, batch these and write periodically
    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        error_log( 'REST API Request: ' . wp_json_encode( $log_entry ) );
    }

    // Flag suspicious patterns
    $suspicious_routes = array( '/wp/v2/users', '/wp/v2/settings' );
    if ( in_array( $request->get_route(), $suspicious_routes, true ) && ! is_user_logged_in() ) {
        do_action( 'wpkite_suspicious_api_access', $log_entry );
    }

    return $result;
}

Putting It All Together: A Layered Defense

Each section of this article addresses a specific attack vector or operational concern. Effective REST API security layers all of them:

Authentication layer: Application Passwords for simple integrations, JWT for headless/mobile clients, OAuth 2.0 for third-party platform access. Choose based on your architecture, not convenience.

Authorization layer: Capability-based permission callbacks on every endpoint. Field-level permission checks that strip sensitive data based on the requester’s role. Never rely on the endpoint-level check alone.

Transport layer: HTTPS everywhere. CORS locked to known origins. Security headers on every response. No exceptions for development convenience in production.

Rate limiting layer: Global rate limits per user and IP. Stricter limits on authentication endpoints. Use object cache when available, transients when not.

Caching layer: Edge caching for public endpoints with proper cache headers. Automatic purging on content changes. Never cache authenticated responses at the edge.

Surface reduction: Disable user enumeration endpoints. Hide the route index from anonymous users. Remove unused block editor and plugin endpoints. Every endpoint that exists is an endpoint that can be attacked.

Visibility layer: Log API access. Monitor for anomalies. Flag unauthenticated access to sensitive routes. Security without monitoring is just hope.

Documentation layer: Auto-generated OpenAPI specs that stay current with your code. Interactive Swagger UI gated behind authentication in production. Good documentation reduces integration errors that create security gaps.

Versioning layer: Namespace-based versions with deprecation headers. Sunset old versions on a published schedule. Breaking changes in unversioned APIs lead to client workarounds that bypass security checks.

The WordPress REST API is powerful infrastructure that defaults to openness. That openness is a feature for public content and a liability for everything else. The patterns in this article give you the tools to keep the useful parts open while locking down everything that needs protection. Test each layer independently, automate the testing with integration tests against your actual endpoints, and review your API surface every time you add a plugin or update WordPress core. Security is ongoing work, not a one-time configuration.

Share this article

David Okonkwo

Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.