WordPress REST API security and best practices
Table of Contents
Introduction
The WordPress REST API has revolutionized how we build modern WordPress applications, enabling headless CMS architectures and seamless integrations. However, with great power comes great responsibility. Securing your REST API endpoints is crucial to prevent unauthorized access, data breaches, and malicious attacks. This comprehensive guide covers essential security best practices to protect your WordPress REST API.
1. Authentication and Authorization
1.1 Implement Proper Authentication
Never rely solely on cookie authentication for REST API requests from external applications. Implement proper authentication methods such as Application Passwords, JWT tokens, or OAuth 2.0.
Application Passwords Implementation
WordPress 5.6+ includes built-in Application Password support. Here’s how to verify and enforce it:
<?php
/**
* Verify application password authentication
*
* @param \WP_User|\WP_Error|null $user User object or error.
* @param string $password Password to check.
* @return \WP_User|\WP_Error User object on success, error on failure.
*/
function custom_validate_application_password( $user, $password ) {
if ( \is_wp_error( $user ) ) {
return $user;
}
// Only apply to REST API requests
if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) {
return $user;
}
// Verify application password
if ( ! \WP_Application_Passwords::is_in_use() ) {
return new \WP_Error(
'application_passwords_disabled',
__( 'Application passwords are not enabled.', 'textdomain' ),
array( 'status' => 403 )
);
}
return $user;
}
\add_filter( 'authenticate', 'custom_validate_application_password', 30, 2 );Custom JWT Authentication
For more control, implement JWT (JSON Web Token) authentication:
<?php
/**
* Generate JWT token for authenticated user
*
* @param \WP_User $user User object.
* @return string JWT token.
*/
function generate_jwt_token( \WP_User $user ) {
$secret_key = defined( 'JWT_AUTH_SECRET_KEY' ) ? JWT_AUTH_SECRET_KEY : '';
if ( empty( $secret_key ) ) {
return new \WP_Error(
'jwt_auth_bad_config',
__( 'JWT is not configured properly.', 'textdomain' )
);
}
$issued_at = time();
$not_before = $issued_at;
$expire = $issued_at + ( DAY_IN_SECONDS * 7 );
$token = array(
'iss' => \get_bloginfo( 'url' ),
'iat' => $issued_at,
'nbf' => $not_before,
'exp' => $expire,
'data' => array(
'user' => array(
'id' => $user->ID,
),
),
);
return \Firebase\JWT\JWT::encode( $token, $secret_key, 'HS256' );
}
/**
* Validate JWT token
*
* @param string $token JWT token.
* @return array|\WP_Error Decoded token data or error.
*/
function validate_jwt_token( $token ) {
$secret_key = defined( 'JWT_AUTH_SECRET_KEY' ) ? JWT_AUTH_SECRET_KEY : '';
try {
$decoded = \Firebase\JWT\JWT::decode( $token, $secret_key, array( 'HS256' ) );
if ( $decoded->exp < time() ) { return new \WP_Error( 'jwt_auth_expired', __( 'Token has expired.', 'textdomain' ), array( 'status' => 401 )
);
}
return $decoded;
} catch ( Exception $e ) {
return new \WP_Error(
'jwt_auth_invalid',
$e->getMessage(),
array( 'status' => 401 )
);
}
}1.2 Capability-Based Authorization
Always check user capabilities before allowing access to sensitive endpoints:
<?php
/**
* Register secure custom REST API endpoint
*/
function register_secure_custom_endpoint() {
\register_rest_route(
'custom/v1',
'/sensitive-data',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => 'get_sensitive_data',
'permission_callback' => 'check_sensitive_data_permissions',
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => function( $param ) {
return \is_numeric( $param );
},
'sanitize_callback' => 'absint',
),
),
)
);
}
\add_action( 'rest_api_init', 'register_secure_custom_endpoint' );
/**
* Check permissions for sensitive data endpoint
*
* @param \WP_REST_Request $request Request object.
* @return bool|\WP_Error True if allowed, error otherwise.
*/
function check_sensitive_data_permissions( \WP_REST_Request $request ) {
if ( ! \is_user_logged_in() ) {
return new \WP_Error(
'rest_forbidden',
__( 'You must be logged in to access this endpoint.', 'textdomain' ),
array( 'status' => 401 )
);
}
if ( ! current_user_can( 'manage_options' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'You do not have permission to access this resource.', 'textdomain' ),
array( 'status' => 403 )
);
}
return true;
}2. Rate Limiting and Throttling
Implement rate limiting to prevent brute force attacks and API abuse. This protects your server resources and prevents malicious actors from overwhelming your endpoints.
<?php
/**
* Implement rate limiting for REST API requests
*/
class REST_API_Rate_Limiter {
/**
* Maximum requests per time window
*
* @var int
*/
private $max_requests = 60;
/**
* Time window in seconds
*
* @var int
*/
private $time_window = 60;
/**
* Initialize rate limiter
*/
public function __construct() {
\add_filter( 'rest_pre_dispatch', array( $this, 'check_rate_limit' ), 10, 3 );
}
/**
* Check rate limit before dispatching request
*
* @param mixed $result Response to replace the requested version with.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request used to generate the response.
* @return mixed|\WP_Error Original result or error if limit exceeded.
*/
public function check_rate_limit( $result, $server, $request ) {
$client_ip = $this->get_client_ip();
$cache_key = 'rest_rate_limit_' . md5( $client_ip );
$requests = \wp_cache_get( $cache_key );
if ( false === $requests ) {
$requests = array(
'count' => 1,
'start_time' => time(),
);
} else {
// Reset counter if time window has passed
if ( time() - $requests['start_time'] > $this->time_window ) {
$requests = array(
'count' => 1,
'start_time' => time(),
);
} else {
$requests['count']++;
}
}
// Check if limit exceeded
if ( $requests['count'] > $this->max_requests ) {
$retry_after = $this->time_window - ( time() - $requests['start_time'] );
return new \WP_Error(
'rest_too_many_requests',
sprintf(
/* translators: %d: seconds until retry */
__( 'Too many requests. Please try again in %d seconds.', 'textdomain' ),
$retry_after
),
array(
'status' => 429,
'retry_after' => $retry_after,
)
);
}
// Update cache
\wp_cache_set( $cache_key, $requests, '', $this->time_window );
return $result;
}
/**
* Get client IP address
*
* @return string Client IP address.
*/
private function get_client_ip() {
$ip_keys = array(
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
);
foreach ( $ip_keys as $key ) {
if ( array_key_exists( $key, $_SERVER ) === true ) {
$ip = sanitize_text_field( \wp_unslash( $_SERVER[ $key ] ) );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ) {
return $ip;
}
}
}
return '0.0.0.0';
}
}
new REST_API_Rate_Limiter();3. Input Validation and Sanitization
Always validate and sanitize input data to prevent injection attacks and ensure data integrity. WordPress provides robust sanitization functions that should be used consistently.
<?php
/**
* Register endpoint with comprehensive input validation
*/
function register_validated_endpoint() {
\register_rest_route(
'secure/v1',
'/user-data',
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => 'handle_user_data',
'permission_callback' => '__return_true',
'args' => array(
'email' => array(
'required' => true,
'type' => 'string',
'description' => __( 'User email address', 'textdomain' ),
'validate_callback' => function( $param, $request, $key ) {
return \is_email( $param );
},
'sanitize_callback' => 'sanitize_email',
),
'name' => array(
'required' => true,
'type' => 'string',
'description' => __( 'User name', 'textdomain' ),
'validate_callback' => function( $param ) {
return ! empty( $param ) && strlen( $param ) <= 100; }, 'sanitize_callback' => 'sanitize_text_field',
),
'age' => array(
'required' => false,
'type' => 'integer',
'description' => __( 'User age', 'textdomain' ),
'validate_callback' => function( $param ) {
return \is_numeric( $param ) && $param >= 0 && $param <= 150; }, 'sanitize_callback' => 'absint',
),
'bio' => array(
'required' => false,
'type' => 'string',
'description' => __( 'User biography', 'textdomain' ),
'validate_callback' => function( $param ) {
return strlen( $param ) <= 1000; }, 'sanitize_callback' => 'sanitize_textarea_field',
),
),
)
);
}
\add_action( 'rest_api_init', 'register_validated_endpoint' );
/**
* Handle user data submission
*
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response|\WP_Error Response or error.
*/
function handle_user_data( \WP_REST_Request $request ) {
$email = $request->get_param( 'email' );
$name = $request->get_param( 'name' );
$age = $request->get_param( 'age' );
$bio = $request->get_param( 'bio' );
// Additional validation
if ( email_exists( $email ) ) {
return new \WP_Error(
'email_exists',
__( 'This email is already registered.', 'textdomain' ),
array( 'status' => 400 )
);
}
// Process data securely
$user_data = array(
'email' => $email,
'name' => $name,
'age' => $age,
'bio' => \wp_kses_post( $bio ),
);
// Store in database with prepared statements
global $wpdb;
$table_name = $wpdb->prefix . 'custom_user_data';
$inserted = $wpdb->insert(
$table_name,
$user_data,
array( '%s', '%s', '%d', '%s' )
);
if ( false === $inserted ) {
return new \WP_Error(
'database_error',
__( 'Failed to save user data.', 'textdomain' ),
array( 'status' => 500 )
);
}
return new \WP_REST_Response(
array(
'success' => true,
'message' => __( 'User data saved successfully.', 'textdomain' ),
'data' => $user_data,
),
201
);
}4. CORS (Cross-Origin Resource Sharing) Configuration
Properly configure CORS headers to control which domains can access your API. Never use wildcards (*) in production environments.
<?php
/**
* Configure CORS headers securely
*
* @param \WP_HTTP_Response $response Current response being sent.
* @return \WP_HTTP_Response Modified response.
*/
function configure_cors_headers( $response ) {
// Define allowed origins (whitelist)
$allowed_origins = array(
'https://example.com',
'https://app.example.com',
'https://mobile.example.com',
);
// Get the origin of the request
$origin = isset( $_SERVER['HTTP_ORIGIN'] )
? sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ORIGIN'] ) )
: '';
// Check if origin is in whitelist
if ( in_array( $origin, $allowed_origins, true ) ) {
$response->header( 'Access-Control-Allow-Origin', $origin );
$response->header( 'Access-Control-Allow-Credentials', 'true' );
}
// Specify allowed methods
$response->header( 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS' );
// Specify allowed headers
$response->header(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, X-WP-Nonce'
);
// Set max age for preflight requests
$response->header( 'Access-Control-Max-Age', '3600' );
return $response;
}
\add_filter( 'rest_pre_serve_request', 'configure_cors_headers' );
/**
* Handle OPTIONS requests for CORS preflight
*/
function handle_cors_preflight() {
if ( 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
status_header( 200 );
exit;
}
}
\add_action( 'rest_api_init', 'handle_cors_preflight', 5 );5. Disable Unnecessary Endpoints
Reduce your attack surface by disabling REST API endpoints that you don’t need, especially for unauthenticated users.
<?php
/**
* Disable REST API endpoints for non-authenticated users
*
* @param \WP_Error|null|bool $result Error from another authentication handler.
* @return \WP_Error|null|bool Error if authentication failed, null otherwise.
*/
function restrict_rest_api_to_authenticated_users( $result ) {
// Allow authentication to proceed normally
if ( true === $result || \is_wp_error( $result ) ) {
return $result;
}
// Check if user is not logged in
if ( ! \is_user_logged_in() ) {
return new \WP_Error(
'rest_not_logged_in',
__( 'You must be logged in to access the REST API.', 'textdomain' ),
array( 'status' => 401 )
);
}
return $result;
}
// Uncomment to enable strict authentication requirement
// \add_filter( 'rest_authentication_errors', 'restrict_rest_api_to_authenticated_users' );
/**
* Disable specific REST API endpoints
*
* @param array $endpoints Available endpoints.
* @return array Modified endpoints.
*/
function disable_unnecessary_endpoints( $endpoints ) {
// Endpoints to disable
$endpoints_to_remove = array(
'/wp/v2/users',
'/wp/v2/users/(?P[\d]+)',
'/wp/v2/comments',
'/wp/v2/settings',
);
foreach ( $endpoints_to_remove as $endpoint ) {
if ( isset( $endpoints[ $endpoint ] ) ) {
unset( $endpoints[ $endpoint ] );
}
}
return $endpoints;
}
\add_filter( 'rest_endpoints', 'disable_unnecessary_endpoints' );
/**
* Remove user endpoints for non-admin users
*
* @param \WP_REST_Response $response Response object.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response|\WP_Error Modified response or error.
*/
function protect_user_endpoints( $response, $server, $request ) {
$route = $request->get_route();
// Protect user endpoints
if ( strpos( $route, '/wp/v2/users' ) === 0 && ! current_user_can( 'manage_options' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Access to user data is restricted.', 'textdomain' ),
array( 'status' => 403 )
);
}
return $response;
}
\add_filter( 'rest_pre_dispatch', 'protect_user_endpoints', 10, 3 );6. Implement Nonce Verification for Cookie Authentication
When using cookie-based authentication (e.g., in JavaScript from your WordPress theme), always verify nonces to prevent CSRF attacks.
<?php
/**
* Secure REST API request with nonce verification
*/
async function makeSecureApiRequest() {
const response = await fetch('/wp-json/custom/v1/protected-data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
credentials: 'same-origin',
body: JSON.stringify({
data: 'sensitive information'
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Request failed');
}
return await response.json();
}
// Usage with error handling
makeSecureApiRequest()
.then(data => {
console.log('Success:', data);
})
.catch(error => {
console.error('Error:', error.message);
});Enqueue the nonce in your theme or plugin:
<?php
/**
* Enqueue script with REST API nonce
*/
function enqueue_api_scripts() {
\wp_enqueue_script(
'custom-api-script',
\get_template_directory_uri() . '/js/api-handler.js',
array(),
'1.0.0',
true
);
\wp_localize_script(
'custom-api-script',
'wpApiSettings',
array(
'root' => esc_url_raw( rest_url() ),
'nonce' => \wp_create_nonce( 'wp_rest' ),
)
);
}
\add_action( 'wp_enqueue_scripts', 'enqueue_api_scripts' );7. Logging and Monitoring
Implement comprehensive logging to track API usage, detect suspicious activity, and troubleshoot issues.
<?php
/**
* REST API request logger
*/
class REST_API_Logger {
/**
* Log table name
*
* @var string
*/
private $table_name;
/**
* Initialize logger
*/
public function __construct() {
global $wpdb;
$this->table_name = $wpdb->prefix . 'rest_api_logs';
\add_filter( 'rest_pre_dispatch', array( $this, 'log_request' ), 10, 3 );
\add_filter( 'rest_post_dispatch', array( $this, 'log_response' ), 10, 3 );
}
/**
* Log incoming request
*
* @param mixed $result Response to replace.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request object.
* @return mixed Original result.
*/
public function log_request( $result, $server, $request ) {
$route = $request->get_route();
$method = $request->get_method();
// Skip logging for certain routes
$skip_routes = array( '/wp/v2/media', '/batch/v1' );
foreach ( $skip_routes as $skip_route ) {
if ( strpos( $route, $skip_route ) === 0 ) {
return $result;
}
}
global $wpdb;
$log_data = array(
'route' => $route,
'method' => $method,
'user_id' => \get_current_user_id(),
'ip_address' => $this->get_client_ip(),
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] )
? sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
: '',
'params' => \wp_json_encode( $request->get_params() ),
'timestamp' => current_time( 'mysql', true ),
);
$wpdb->insert(
$this->table_name,
$log_data,
array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
);
return $result;
}
/**
* Log response
*
* @param \WP_HTTP_Response $result Result to send.
* @param \WP_REST_Server $server Server instance.
* @param \WP_REST_Request $request Request object.
* @return \WP_HTTP_Response Original result.
*/
public function log_response( $result, $server, $request ) {
// Check for errors or suspicious activity
if ( \is_wp_error( $result ) ) {
$this->log_error( $request, $result );
}
return $result;
}
/**
* Log errors separately for easier monitoring
*
* @param \WP_REST_Request $request Request object.
* @param \WP_Error $error Error object.
*/
private function log_error( $request, $error ) {
error_log(
sprintf(
'REST API Error: %s - Route: %s - User: %d - IP: %s',
$error->get_error_message(),
$request->get_route(),
\get_current_user_id(),
$this->get_client_ip()
)
);
// Alert on repeated failures
$this->check_for_attacks( $request );
}
/**
* Detect potential attacks based on failed requests
*
* @param \WP_REST_Request $request Request object.
*/
private function check_for_attacks( $request ) {
global $wpdb;
$ip = $this->get_client_ip();
$one_hour_ago = gmdate( 'Y-m-d H:i:s', strtotime( '-1 hour' ) );
$failed_attempts = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table_name}
WHERE ip_address = %s
AND timestamp > %s
AND status >= 400",
$ip,
$one_hour_ago
)
);
// Block IP if too many failed attempts
if ( $failed_attempts > 20 ) {
// Implement blocking mechanism
$this->block_ip( $ip );
}
}
/**
* Block suspicious IP address
*
* @param string $ip IP address to block.
*/
private function block_ip( $ip ) {
update_option( 'blocked_ips_' . md5( $ip ), time() + HOUR_IN_SECONDS );
// Send alert to admin
\wp_mail(
\get_option( 'admin_email' ),
'REST API Security Alert',
sprintf(
'IP address %s has been temporarily blocked due to suspicious activity.',
$ip
)
);
}
/**
* Get client IP address
*
* @return string Client IP.
*/
private function get_client_ip() {
$ip_keys = array(
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'REMOTE_ADDR',
);
foreach ( $ip_keys as $key ) {
if ( array_key_exists( $key, $_SERVER ) ) {
$ip = sanitize_text_field( \wp_unslash( $_SERVER[ $key ] ) );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip;
}
}
}
return '0.0.0.0';
}
}
new REST_API_Logger();8. Security Headers
Add security headers to your REST API responses to prevent common vulnerabilities.
<?php
/**
* Add security headers to REST API responses
*
* @param \WP_HTTP_Response $response Response object.
* @return \WP_HTTP_Response Modified response.
*/
function add_rest_api_security_headers( $response ) {
// Prevent MIME type sniffing
$response->header( 'X-Content-Type-Options', 'nosniff' );
// Enable XSS protection
$response->header( 'X-XSS-Protection', '1; mode=block' );
// Prevent clickjacking
$response->header( 'X-Frame-Options', 'DENY' );
// Content Security Policy
$response->header(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self';"
);
// Referrer Policy
$response->header( 'Referrer-Policy', 'strict-origin-when-cross-origin' );
// Permissions Policy
$response->header(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=()'
);
return $response;
}
\add_filter( 'rest_pre_serve_request', 'add_rest_api_security_headers' );9. Data Filtering and Privacy
Filter sensitive data from API responses to prevent information disclosure.
<?php
/**
* Filter sensitive user data from REST API responses
*
* @param \WP_REST_Response $response Response object.
* @param \WP_User $user User object.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response Modified response.
*/
function filter_sensitive_user_data( $response, $user, $request ) {
$data = $response->get_data();
// Remove sensitive fields
$sensitive_fields = array(
'capabilities',
'extra_capabilities',
'roles',
'email',
);
foreach ( $sensitive_fields as $field ) {
if ( isset( $data[ $field ] ) ) {
unset( $data[ $field ] );
}
}
// Only show email to the user themselves or admins
if ( ! ( \get_current_user_id() === $user->ID || current_user_can( 'manage_options' ) ) ) {
if ( isset( $data['email'] ) ) {
unset( $data['email'] );
}
}
$response->set_data( $data );
return $response;
}
\add_filter( 'rest_prepare_user', 'filter_sensitive_user_data', 10, 3 );
/**
* Remove user enumeration capability
*
* @param array $prepared_args Prepared arguments.
* @param \WP_REST_Request $request Request object.
* @return array Modified arguments.
*/
function prevent_user_enumeration( $prepared_args, $request ) {
if ( ! current_user_can( 'list_users' ) ) {
return new \WP_Error(
'rest_user_cannot_list',
__( 'Sorry, you are not allowed to list users.', 'textdomain' ),
array( 'status' => rest_authorization_required_code() )
);
}
return $prepared_args;
}
\add_filter( 'rest_user_query', 'prevent_user_enumeration', 10, 2 );10. Best Practices Checklist
- Use HTTPS: Always serve your REST API over HTTPS to encrypt data in transit
- Keep WordPress Updated: Regularly update WordPress core, plugins, and themes
- Use Strong Authentication: Implement Application Passwords or JWT authentication
- Validate All Input: Never trust user input; always validate and sanitize
- Implement Rate Limiting: Protect against brute force and DDoS attacks
- Check Capabilities: Always verify user permissions before granting access
- Use Prepared Statements: Prevent SQL injection with $wpdb->prepare()
- Configure CORS Properly: Use whitelists; avoid wildcards in production
- Log Suspicious Activity: Monitor and alert on unusual patterns
- Minimize Data Exposure: Only return necessary data in API responses
- Disable Unused Endpoints: Reduce attack surface by limiting available endpoints
- Implement Security Headers: Add headers to prevent common vulnerabilities
- Regular Security Audits: Periodically review and test your API security
- Error Handling: Don’t expose sensitive information in error messages
- Version Your API: Use versioning to manage changes without breaking existing integrations
Conclusion
Securing your WordPress REST API requires a multi-layered approach combining authentication, authorization, input validation, rate limiting, and monitoring. By implementing these best practices and following WordPress Coding Standards (WPCS), you can significantly reduce vulnerabilities and protect your WordPress site from malicious attacks.
Remember that security is an ongoing process, not a one-time task. Stay informed about new vulnerabilities, regularly update your code, and continuously monitor your API for suspicious activity. With proper implementation of these security measures, you can confidently build robust and secure WordPress applications using the REST API.