WordPress REST API Security: 7 Critical Mistakes to Avoid

I’ve audited dozens of WordPress sites with custom REST API endpoints, and I keep seeing the same security mistakes over and over. The WordPress REST API is incredibly powerful, but it’s also a direct gateway into your application’s backend. One misconfigured endpoint can expose sensitive data, allow unauthorized modifications, or worse—give attackers administrative access.

In this guide, I’ll walk you through the seven most critical WordPress REST API security mistakes I encounter in real projects, along with the exact code fixes you need to implement. These aren’t theoretical vulnerabilities—these are actual security holes I’ve found in production sites that handle millions of dollars in transactions.

Why WordPress REST API Security Matters More Than Ever

The WordPress REST API has become the backbone of modern WordPress development. Whether you’re building headless WordPress sites, mobile apps, or complex integrations, you’re likely exposing data through REST endpoints. But here’s the problem: WordPress’s default permissive approach to API access can be dangerous if you don’t implement proper security measures.

I recently worked with a client whose e-commerce site was hemorrhaging customer data through an improperly secured custom endpoint. The endpoint was designed to return order information for logged-in users, but due to a missing permission check, anyone could access any order by simply changing the order ID in the URL. The fix took five minutes to implement, but the damage to their reputation was significant.

Mistake #1: Missing Permission Callbacks

This is the most common and dangerous mistake I see. When registering a REST route, developers often forget to implement proper permission callbacks, leaving endpoints completely open to unauthorized access.

The Problem: Without permission callbacks, your endpoints are publicly accessible by default. Even if you think your endpoint is “secure” because it requires specific parameters, anyone can discover and exploit it.

// DANGEROUS: No permission callback
register_rest_route( 'myapp/v1', '/orders/(?P<id>d+)', array(
    'methods' => 'GET',
    'callback' => 'get_order_details',
    'args' => array(
        'id' => array(
            'validate_callback' => function($param, $request, $key) {
                return is_numeric($param);
            }
        ),
    ),
));

The Fix: Always implement a permission callback that validates the user’s authorization to access the endpoint.

// SECURE: Proper permission callback
register_rest_route( 'myapp/v1', '/orders/(?P<id>d+)', array(
    'methods' => 'GET',
    'callback' => 'get_order_details',
    'permission_callback' => function( $request ) {
        // Check if user is logged in
        if ( ! is_user_logged_in() ) {
            return new WP_Error(
                'rest_forbidden', 
                __( 'You must be logged in to access orders.' ), 
                array( 'status' => 401 )
            );
        }
        
        // Check if user owns this order or has admin capabilities
        $order_id = $request->get_param('id');
        $order = get_post( $order_id );
        
        if ( ! $order || $order->post_type !== 'shop_order' ) {
            return false;
        }
        
        $current_user = wp_get_current_user();
        $order_user_id = get_post_meta( $order_id, '_customer_user', true );
        
        return $current_user->ID == $order_user_id || current_user_can( 'manage_woocommerce' );
    },
    'args' => array(
        'id' => array(
            'validate_callback' => function($param, $request, $key) {
                return is_numeric($param);
            }
        ),
    ),
));

Advanced Permission Patterns

For complex applications, I create reusable permission classes that handle common authorization patterns. This approach keeps your route registration clean and ensures consistent security across all endpoints.

class API_Permissions {
    
    public static function user_owns_resource_or_admin( $resource_id, $resource_type = 'post' ) {
        return function( $request ) use ( $resource_id, $resource_type ) {
            if ( ! is_user_logged_in() ) {
                return new WP_Error( 'rest_forbidden', __( 'Authentication required.' ), array( 'status' => 401 ) );
            }
            
            if ( current_user_can( 'manage_options' ) ) {
                return true;
            }
            
            $current_user = wp_get_current_user();
            
            if ( $resource_type === 'post' ) {
                $post = get_post( $resource_id );
                return $post && $post->post_author == $current_user->ID;
            }
            
            // Add other resource type checks as needed
            return false;
        };
    }
    
    public static function minimum_role( $minimum_role = 'subscriber' ) {
        return function( $request ) use ( $minimum_role ) {
            if ( ! is_user_logged_in() ) {
                return new WP_Error( 'rest_forbidden', __( 'Authentication required.' ), array( 'status' => 401 ) );
            }
            
            return current_user_can( $minimum_role );
        };
    }
}

Mistake #2: Inadequate Input Validation and Sanitization

WordPress developers often rely on WordPress’s built-in sanitization functions for form handling, but REST API endpoints require more explicit validation. I’ve seen endpoints that accept any data without validation, leading to SQL injection, XSS, and data corruption issues.

The Problem: REST API parameters aren’t automatically sanitized like traditional WordPress form submissions. You need to explicitly validate and sanitize every input parameter.

// DANGEROUS: No input validation
function update_user_profile( $request ) {
    $user_id = $request['user_id'];
    $email = $request['email'];
    $bio = $request['bio'];
    
    // Direct database update without validation
    wp_update_user(array(
        'ID' => $user_id,
        'user_email' => $email,
        'description' => $bio
    ));
    
    return new WP_REST_Response( 'Profile updated', 200 );
}

The Fix: Implement comprehensive validation and sanitization for all input parameters using WordPress’s built-in functions and custom validation logic.

// SECURE: Proper input validation and sanitization
register_rest_route( 'myapp/v1', '/user/(?P<user_id>d+)', array(
    'methods' => 'PUT',
    'callback' => 'update_user_profile',
    'permission_callback' => 'user_can_edit_profile',
    'args' => array(
        'user_id' => array(
            'required' => true,
            'validate_callback' => function($param, $request, $key) {
                return is_numeric($param) && $param > 0;
            },
            'sanitize_callback' => 'absint'
        ),
        'email' => array(
            'required' => false,
            'validate_callback' => function($param, $request, $key) {
                return is_email($param);
            },
            'sanitize_callback' => 'sanitize_email'
        ),
        'bio' => array(
            'required' => false,
            'validate_callback' => function($param, $request, $key) {
                return strlen($param) <= 500; // Limit bio length
            },
            'sanitize_callback' => function($param, $request, $key) {
                return sanitize_textarea_field($param);
            }
        ),
        'display_name' => array(
            'required' => false,
            'validate_callback' => function($param, $request, $key) {
                return strlen($param) >= 2 && strlen($param) <= 50;
            },
            'sanitize_callback' => 'sanitize_text_field'
        )
    ),
));

function update_user_profile( $request ) {
    $user_id = $request->get_param('user_id');
    $email = $request->get_param('email');
    $bio = $request->get_param('bio');
    $display_name = $request->get_param('display_name');
    
    // Additional business logic validation
    if ( $email && email_exists($email) && email_exists($email) != $user_id ) {
        return new WP_Error(
            'email_exists',
            __('This email address is already in use.'),
            array('status' => 400)
        );
    }
    
    $update_data = array('ID' => $user_id);
    
    if ( $email ) {
        $update_data['user_email'] = $email;
    }
    
    if ( $bio !== null ) {
        $update_data['description'] = $bio;
    }
    
    if ( $display_name ) {
        $update_data['display_name'] = $display_name;
    }
    
    $result = wp_update_user($update_data);
    
    if ( is_wp_error($result) ) {
        return new WP_Error(
            'update_failed',
            __('Failed to update user profile.'),
            array('status' => 500)
        );
    }
    
    return new WP_REST_Response(array(
        'message' => 'Profile updated successfully',
        'user_id' => $user_id
    ), 200);
}

Mistake #3: Exposing Sensitive Data in Response Objects

WordPress REST API responses often include more data than necessary. I’ve seen endpoints that return complete user objects with password hashes, sensitive meta data, and internal system information that should never be exposed to clients.

The Problem: Using WordPress’s default REST response preparation can expose sensitive fields. Many developers simply return raw database objects without filtering sensitive information.

Creating Secure Response Objects

Always create explicit response objects that only include the data your client application needs. Never return raw WordPress objects or database results directly.

// DANGEROUS: Returning raw user object
function get_user_profile( $request ) {
    $user_id = $request['user_id'];
    $user = get_userdata($user_id);
    
    // This exposes password hashes, email, and other sensitive data
    return new WP_REST_Response( $user, 200 );
}

// SECURE: Filtered response object
function get_user_profile( $request ) {
    $user_id = $request->get_param('user_id');
    $user = get_userdata($user_id);
    
    if ( ! $user ) {
        return new WP_Error(
            'user_not_found',
            __('User not found.'),
            array('status' => 404)
        );
    }
    
    // Create filtered response object
    $response_data = array(
        'id' => $user->ID,
        'display_name' => $user->display_name,
        'avatar_url' => get_avatar_url($user->ID),
        'bio' => get_user_meta($user->ID, 'description', true),
        'registration_date' => $user->user_registered,
        'public_meta' => get_filtered_user_meta($user->ID)
    );
    
    // Only include email for the user themselves or admins
    if ( get_current_user_id() === $user->ID || current_user_can('manage_users') ) {
        $response_data['email'] = $user->user_email;
        $response_data['role'] = $user->roles[0] ?? 'subscriber';
    }
    
    return new WP_REST_Response( $response_data, 200 );
}

function get_filtered_user_meta( $user_id ) {
    // Only return safe, public meta fields
    $allowed_meta_keys = array(
        'company',
        'website',
        'twitter',
        'linkedin'
    );
    
    $filtered_meta = array();
    
    foreach ( $allowed_meta_keys as $key ) {
        $value = get_user_meta($user_id, $key, true);
        if ( $value ) {
            $filtered_meta[$key] = sanitize_text_field($value);
        }
    }
    
    return $filtered_meta;
}

Mistake #4: Insufficient Rate Limiting and DDoS Protection

REST APIs are particularly vulnerable to abuse because they’re designed for automated access. Without proper rate limiting, attackers can overwhelm your server with requests, perform brute force attacks, or abuse expensive operations like search or data export.

The Problem: WordPress doesn’t include built-in rate limiting for REST API endpoints. A single script can make thousands of requests per minute, potentially bringing down your server or racking up huge hosting bills.

The Solution: Implement rate limiting using transients or a dedicated caching layer. Here’s a robust rate limiting system I use in production:

class API_Rate_Limiter {
    
    private static $instance = null;
    private $default_limits = array(
        'anonymous' => array('requests' => 100, 'window' => 3600), // 100 requests per hour
        'authenticated' => array('requests' => 1000, 'window' => 3600), // 1000 requests per hour
        'admin' => array('requests' => 5000, 'window' => 3600) // 5000 requests per hour
    );
    
    public static function get_instance() {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    public function check_rate_limit( $identifier = null, $custom_limits = null ) {
        if ( ! $identifier ) {
            $identifier = $this->get_client_identifier();
        }
        
        $limits = $custom_limits ?: $this->get_user_limits();
        $cache_key = 'api_rate_limit_' . md5($identifier);
        $current_count = get_transient($cache_key) ?: 0;
        
        // Check if limit exceeded
        if ( $current_count >= $limits['requests'] ) {
            return new WP_Error(
                'rate_limit_exceeded',
                sprintf(
                    __('Rate limit exceeded. Maximum %d requests per %d seconds allowed.'),
                    $limits['requests'],
                    $limits['window']
                ),
                array(
                    'status' => 429,
                    'headers' => array(
                        'X-RateLimit-Limit' => $limits['requests'],
                        'X-RateLimit-Remaining' => 0,
                        'X-RateLimit-Reset' => time() + $limits['window']
                    )
                )
            );
        }
        
        // Increment counter
        set_transient($cache_key, $current_count + 1, $limits['window']);
        
        return array(
            'allowed' => true,
            'limit' => $limits['requests'],
            'remaining' => $limits['requests'] - ($current_count + 1),
            'reset' => time() + $limits['window']
        );
    }
    
    private function get_client_identifier() {
        // Use user ID for authenticated requests
        if ( is_user_logged_in() ) {
            return 'user_' . get_current_user_id();
        }
        
        // Use IP address for anonymous requests
        return 'ip_' . $this->get_client_ip();
    }
    
    private function get_client_ip() {
        $ip_headers = array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR');
        
        foreach ($ip_headers as $header) {
            if (!empty($_SERVER[$header])) {
                $ip = $_SERVER[$header];
                if (strpos($ip, ',') !== false) {
                    $ip = trim(explode(',', $ip)[0]);
                }
                if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                    return $ip;
                }
            }
        }
        
        return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    }
    
    private function get_user_limits() {
        if ( ! is_user_logged_in() ) {
            return $this->default_limits['anonymous'];
        }
        
        if ( current_user_can('manage_options') ) {
            return $this->default_limits['admin'];
        }
        
        return $this->default_limits['authenticated'];
    }
}

// Usage in REST route registration
function register_rate_limited_endpoint() {
    register_rest_route( 'myapp/v1', '/search', array(
        'methods' => 'GET',
        'callback' => 'handle_search_request',
        'permission_callback' => function( $request ) {
            // Apply rate limiting before permission check
            $rate_limiter = API_Rate_Limiter::get_instance();
            $rate_check = $rate_limiter->check_rate_limit(null, array(
                'requests' => 50, // Stricter limit for expensive search operations
                'window' => 3600
            ));
            
            if ( is_wp_error($rate_check) ) {
                return $rate_check;
            }
            
            // Add rate limit headers to response
            add_filter('rest_post_dispatch', function($response, $server, $request) use ($rate_check) {
                $response->header('X-RateLimit-Limit', $rate_check['limit']);
                $response->header('X-RateLimit-Remaining', $rate_check['remaining']);
                $response->header('X-RateLimit-Reset', $rate_check['reset']);
                return $response;
            }, 10, 3);
            
            return true;
        },
        'args' => array(
            'query' => array(
                'required' => true,
                'validate_callback' => function($param) {
                    return strlen($param) >= 3 && strlen($param) <= 100;
                },
                'sanitize_callback' => 'sanitize_text_field'
            )
        )
    ));
}

Mistake #5: Inadequate Error Handling and Information Disclosure

Poor error handling in REST APIs can leak sensitive information about your system architecture, database structure, or internal processes. I’ve seen endpoints that return raw MySQL errors, file paths, or stack traces that give attackers valuable information for planning attacks.

The Problem: Generic error handling that doesn’t differentiate between user-facing errors and system errors, leading to information disclosure vulnerabilities.

Implementing Secure Error Response Patterns

Create a centralized error handling system that sanitizes error messages and logs detailed information securely without exposing it to API consumers.

class Secure_API_Error_Handler {
    
    // Safe, user-facing error messages
    private static $safe_errors = array(
        'invalid_credentials' => 'Invalid username or password.',
        'insufficient_permissions' => 'You do not have permission to perform this action.',
        'validation_failed' => 'The submitted data is invalid.',
        'resource_not_found' => 'The requested resource was not found.',
        'rate_limit_exceeded' => 'Too many requests. Please try again later.',
        'maintenance_mode' => 'The service is temporarily unavailable for maintenance.'
    );
    
    public static function handle_error( $error, $context = array() ) {
        // Log detailed error information securely
        if ( WP_DEBUG_LOG ) {
            error_log(sprintf(
                '[API Error] %s - Context: %s - User: %d - IP: %s - Time: %s',
                is_wp_error($error) ? $error->get_error_message() : $error,
                json_encode($context),
                get_current_user_id(),
                $_SERVER['REMOTE_ADDR'] ?? 'unknown',
                current_time('mysql')
            ));
        }
        
        // Determine error type and return appropriate response
        if ( is_wp_error($error) ) {
            $error_code = $error->get_error_code();
            $error_data = $error->get_error_data();
            
            // Return safe error message or generic message
            $safe_message = self::$safe_errors[$error_code] ?? 'An error occurred while processing your request.';
            
            return new WP_REST_Response(
                array(
                    'error' => array(
                        'code' => $error_code,
                        'message' => $safe_message,
                        'timestamp' => time()
                    )
                ),
                $error_data['status'] ?? 500
            );
        }
        
        // Handle unexpected errors
        return new WP_REST_Response(
            array(
                'error' => array(
                    'code' => 'internal_error',
                    'message' => 'An unexpected error occurred.',
                    'timestamp' => time()
                )
            ),
            500
        );
    }
    
    public static function validate_and_sanitize_input( $data, $schema ) {
        $sanitized = array();
        $errors = array();
        
        foreach ( $schema as $field => $rules ) {
            $value = $data[$field] ?? null;
            
            // Check required fields
            if ( $rules['required'] && empty($value) ) {
                $errors[$field] = sprintf('%s is required.', $rules['label'] ?? $field);
                continue;
            }
            
            // Skip validation for optional empty fields
            if ( empty($value) && ! $rules['required'] ) {
                continue;
            }
            
            // Apply validation rules
            if ( isset($rules['validate']) && ! call_user_func($rules['validate'], $value) ) {
                $errors[$field] = $rules['error_message'] ?? sprintf('%s is invalid.', $rules['label'] ?? $field);
                continue;
            }
            
            // Apply sanitization
            if ( isset($rules['sanitize']) ) {
                $sanitized[$field] = call_user_func($rules['sanitize'], $value);
            } else {
                $sanitized[$field] = sanitize_text_field($value);
            }
        }
        
        if ( ! empty($errors) ) {
            return new WP_Error(
                'validation_failed',
                'Validation failed.',
                array(
                    'status' => 400,
                    'validation_errors' => $errors
                )
            );
        }
        
        return $sanitized;
    }
}

// Usage example
function create_order_endpoint( $request ) {
    try {
        $schema = array(
            'customer_email' => array(
                'required' => true,
                'label' => 'Customer Email',
                'validate' => 'is_email',
                'sanitize' => 'sanitize_email',
                'error_message' => 'Please provide a valid email address.'
            ),
            'order_total' => array(
                'required' => true,
                'label' => 'Order Total',
                'validate' => function($value) { return is_numeric($value) && $value > 0; },
                'sanitize' => function($value) { return round(floatval($value), 2); },
                'error_message' => 'Order total must be a positive number.'
            )
        );
        
        $validated_data = Secure_API_Error_Handler::validate_and_sanitize_input(
            $request->get_json_params(),
            $schema
        );
        
        if ( is_wp_error($validated_data) ) {
            return Secure_API_Error_Handler::handle_error($validated_data);
        }
        
        // Process order creation
        $order_id = create_order($validated_data);
        
        if ( ! $order_id ) {
            return Secure_API_Error_Handler::handle_error(
                new WP_Error('order_creation_failed', 'Order creation failed.', array('status' => 500))
            );
        }
        
        return new WP_REST_Response(array(
            'success' => true,
            'order_id' => $order_id,
            'message' => 'Order created successfully.'
        ), 201);
        
    } catch ( Exception $e ) {
        return Secure_API_Error_Handler::handle_error($e, array(
            'endpoint' => 'create_order',
            'request_data' => $request->get_json_params()
        ));
    }
}

Mistake #6: Missing HTTPS and Insecure Data Transmission

This might seem obvious, but I still encounter production WordPress sites with REST APIs running over HTTP. Any API that handles authentication, personal data, or sensitive information must use HTTPS to prevent man-in-the-middle attacks and data interception.

The Problem: HTTP transmits data in plain text, making it trivial for attackers to intercept authentication tokens, personal information, and API responses. This is especially dangerous for mobile apps or public WiFi users.

The Solution: Enforce HTTPS at both the server and application level. Here’s how to implement HTTPS enforcement in your WordPress REST API:

// Force HTTPS for all API endpoints
function force_api_https() {
    // Check if we're on an API endpoint
    if ( strpos($_SERVER['REQUEST_URI'], '/wp-json/') === 0 || 
         strpos($_SERVER['REQUEST_URI'], '/?rest_route=') !== false ) {
        
        // Check if HTTPS is not enabled
        if ( ! is_ssl() && ! wp_doing_ajax() ) {
            // Redirect to HTTPS version
            $redirect_url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
            wp_redirect($redirect_url, 301);
            exit();
        }
    }
}
add_action('init', 'force_api_https', 1);

// Add security headers for API responses
function add_api_security_headers( $response, $server, $request ) {
    $response->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
    $response->header('X-Content-Type-Options', 'nosniff');
    $response->header('X-Frame-Options', 'DENY');
    $response->header('X-XSS-Protection', '1; mode=block');
    $response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
    
    return $response;
}
add_filter('rest_post_dispatch', 'add_api_security_headers', 10, 3);

Mistake #7: Weak Authentication and Session Management

WordPress’s default cookie-based authentication isn’t suitable for REST API consumption, especially for mobile apps or third-party integrations. Many developers either disable authentication entirely or implement weak token-based systems that are vulnerable to attack.

The Problem: Cookie-based authentication is vulnerable to CSRF attacks and doesn’t work well with mobile applications. Basic authentication sends credentials with every request, and custom token implementations often lack proper expiration, rotation, or revocation mechanisms.

The Solution: Implement a proper JWT or Application Password authentication system with token expiration, rotation, and secure storage.

Implementing Secure API Authentication

Here’s a production-ready authentication system I use for WordPress REST APIs that handles token generation, validation, and secure rotation:

class Secure_API_Authentication {
    
    private static $token_expiry = 3600; // 1 hour
    private static $refresh_token_expiry = 86400 * 30; // 30 days
    
    public static function authenticate_request( $user ) {
        // Skip if already authenticated
        if ( $user instanceof WP_User ) {
            return $user;
        }
        
        // Check for authorization header
        $auth_header = self::get_auth_header();
        
        if ( ! $auth_header ) {
            return $user;
        }
        
        // Extract and validate token
        if ( strpos($auth_header, 'Bearer ') === 0 ) {
            $token = substr($auth_header, 7);
            return self::validate_jwt_token($token);
        }
        
        return $user;
    }
    
    private static function get_auth_header() {
        // Check Authorization header
        if ( isset($_SERVER['HTTP_AUTHORIZATION']) ) {
            return $_SERVER['HTTP_AUTHORIZATION'];
        }
        
        // Apache sometimes doesn't pass the Authorization header
        if ( function_exists('apache_request_headers') ) {
            $headers = apache_request_headers();
            if ( isset($headers['Authorization']) ) {
                return $headers['Authorization'];
            }
        }
        
        return null;
    }
    
    public static function generate_tokens( $user_id ) {
        $current_time = time();
        
        // Generate access token
        $access_token_payload = array(
            'user_id' => $user_id,
            'exp' => $current_time + self::$token_expiry,
            'iat' => $current_time,
            'type' => 'access'
        );
        
        // Generate refresh token
        $refresh_token_payload = array(
            'user_id' => $user_id,
            'exp' => $current_time + self::$refresh_token_expiry,
            'iat' => $current_time,
            'type' => 'refresh',
            'jti' => wp_generate_uuid4() // Unique token ID for revocation
        );
        
        $access_token = self::create_jwt_token($access_token_payload);
        $refresh_token = self::create_jwt_token($refresh_token_payload);
        
        // Store refresh token in database for revocation tracking
        update_user_meta($user_id, 'api_refresh_tokens', array(
            $refresh_token_payload['jti'] => array(
                'token' => $refresh_token,
                'expires' => $refresh_token_payload['exp'],
                'created' => $current_time
            )
        ));
        
        return array(
            'access_token' => $access_token,
            'refresh_token' => $refresh_token,
            'expires_in' => self::$token_expiry,
            'token_type' => 'Bearer'
        );
    }
    
    private static function create_jwt_token( $payload ) {
        // Simple JWT implementation - in production, use a proper JWT library
        $header = json_encode(array('typ' => 'JWT', 'alg' => 'HS256'));
        $payload_json = json_encode($payload);
        
        $header_encoded = self::base64url_encode($header);
        $payload_encoded = self::base64url_encode($payload_json);
        
        $signature = hash_hmac(
            'sha256',
            $header_encoded . '.' . $payload_encoded,
            self::get_jwt_secret(),
            true
        );
        
        $signature_encoded = self::base64url_encode($signature);
        
        return $header_encoded . '.' . $payload_encoded . '.' . $signature_encoded;
    }
    
    private static function validate_jwt_token( $token ) {
        $parts = explode('.', $token);
        
        if ( count($parts) !== 3 ) {
            return null;
        }
        
        list($header_encoded, $payload_encoded, $signature_encoded) = $parts;
        
        // Verify signature
        $signature = self::base64url_decode($signature_encoded);
        $expected_signature = hash_hmac(
            'sha256',
            $header_encoded . '.' . $payload_encoded,
            self::get_jwt_secret(),
            true
        );
        
        if ( ! hash_equals($signature, $expected_signature) ) {
            return null;
        }
        
        // Decode and validate payload
        $payload = json_decode(self::base64url_decode($payload_encoded), true);
        
        if ( ! $payload || $payload['exp'] < time() ) {
            return null;
        }
        
        // Check if token is revoked (for refresh tokens)
        if ( $payload['type'] === 'refresh' ) {
            $stored_tokens = get_user_meta($payload['user_id'], 'api_refresh_tokens', true) ?: array();
            if ( ! isset($stored_tokens[$payload['jti']]) ) {
                return null; // Token has been revoked
            }
        }
        
        return get_user_by('ID', $payload['user_id']);
    }
    
    private static function get_jwt_secret() {
        // Use WordPress secret keys for JWT signing
        return hash('sha256', AUTH_KEY . SECURE_AUTH_KEY);
    }
    
    private static function base64url_encode( $data ) {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
    
    private static function base64url_decode( $data ) {
        return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
    }
    
    public static function revoke_refresh_token( $user_id, $token_id ) {
        $stored_tokens = get_user_meta($user_id, 'api_refresh_tokens', true) ?: array();
        unset($stored_tokens[$token_id]);
        update_user_meta($user_id, 'api_refresh_tokens', $stored_tokens);
    }
}

// Hook into WordPress authentication
add_filter('determine_current_user', array('Secure_API_Authentication', 'authenticate_request'), 20);

Essential WordPress REST API Security Checklist

Here’s a quick checklist I use when auditing WordPress REST API security. Print this out and use it for every API endpoint you create:

  • Permission Callbacks: Every endpoint has a proper permission_callback that validates user authorization
  • Input Validation: All parameters have validate_callback and sanitize_callback functions
  • Response Filtering: API responses only include necessary data, never raw database objects
  • Rate Limiting: Endpoints have appropriate rate limits based on their resource requirements
  • Error Handling: Errors return safe messages without exposing system information
  • HTTPS Enforcement: All API traffic is forced over HTTPS with proper security headers
  • Authentication: Strong token-based authentication with proper expiration and revocation
  • Logging: Security events are logged without exposing sensitive data in logs
  • CORS Configuration: Cross-origin requests are properly configured for your use case
  • API Versioning: Endpoints include version numbers for future security updates

Implementing Security from Day One

Security isn’t something you bolt on later—it needs to be built into your development process from the beginning. When I start a new WordPress project with REST API requirements, I create a security baseline that includes all these protections as reusable components.

The most important lesson I’ve learned is that WordPress REST API security requires a layered approach. No single security measure is sufficient on its own. You need proper authentication, thorough input validation, careful output filtering, rate limiting, and comprehensive error handling working together.

Start by implementing these seven security practices in your current projects. Your future self (and your clients) will thank you when your APIs remain secure and performant under real-world conditions. Remember: the cost of implementing proper security upfront is always less than the cost of fixing a security breach later.