WordPress Security for Headless and Decoupled Sites: API Protection
Secure headless WordPress implementations with API authentication, CORS configuration, rate limiting, and frontend protection strategies.
Headless WordPress architectures introduce unique security considerations. When WordPress serves only as a backend API, traditional security measures must be adapted and new protections implemented.
Headless Security Challenges
Decoupled architectures face specific risks:
- API endpoint exposure
- Cross-origin request handling
- Authentication token management
- Data over-exposure in responses
- Rate limiting across distributed frontends
API Authentication
Implement secure API authentication:
// JWT authentication for headless WordPress
function generate_jwt_token($user_id) {
$secret = defined('JWT_SECRET') ? JWT_SECRET : wp_salt('auth');
$issued_at = time();
$expiration = $issued_at + (HOUR_IN_SECONDS * 24); // 24 hours
$payload = array(
'iss' => home_url(),
'iat' => $issued_at,
'exp' => $expiration,
'user_id' => $user_id,
'roles' => get_user_roles($user_id),
);
$header = base64_encode(json_encode(array('typ' => 'JWT', 'alg' => 'HS256')));
$payload_encoded = base64_encode(json_encode($payload));
$signature = hash_hmac('sha256', $header . '.' . $payload_encoded, $secret);
return $header . '.' . $payload_encoded . '.' . base64_encode($signature);
}
function verify_jwt_token($token) {
$secret = defined('JWT_SECRET') ? JWT_SECRET : wp_salt('auth');
$parts = explode('.', $token);
if (count($parts) !== 3) {
return false;
}
list($header, $payload, $signature) = $parts;
// Verify signature
$expected_signature = base64_encode(
hash_hmac('sha256', $header . '.' . $payload, $secret)
);
if (!hash_equals($expected_signature, $signature)) {
return false;
}
// Decode and verify payload
$payload_data = json_decode(base64_decode($payload), true);
if ($payload_data['exp'] < time()) {
return false; // Token expired
}
return $payload_data;
}
// Authentication endpoint
add_action('rest_api_init', function() {
register_rest_route('auth/v1', '/token', array(
'methods' => 'POST',
'callback' => 'handle_token_request',
'permission_callback' => '__return_true',
));
});
function handle_token_request($request) {
$username = sanitize_user($request->get_param('username'));
$password = $request->get_param('password');
$user = wp_authenticate($username, $password);
if (is_wp_error($user)) {
return new WP_Error('invalid_credentials', 'Invalid credentials', array('status' => 401));
}
$token = generate_jwt_token($user->ID);
$refresh_token = generate_refresh_token($user->ID);
return array(
'token' => $token,
'refresh_token' => $refresh_token,
'expires_in' => HOUR_IN_SECONDS * 24,
);
}
CORS Configuration
Properly configure cross-origin requests:
function configure_cors_headers() {
$allowed_origins = array(
'https://frontend.example.com',
'https://www.example.com',
'https://staging.example.com',
);
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Max-Age: 86400'); // 24 hours
}
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
}
add_action('rest_api_init', 'configure_cors_headers', 1);
// Block requests from unauthorized origins
add_filter('rest_pre_dispatch', function($result, $server, $request) {
$allowed_origins = array(
'https://frontend.example.com',
'https://www.example.com',
);
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
// Allow requests without origin (server-to-server)
if (empty($origin)) {
return $result;
}
if (!in_array($origin, $allowed_origins)) {
return new WP_Error(
'cors_forbidden',
'Origin not allowed',
array('status' => 403)
);
}
return $result;
}, 10, 3);
API Rate Limiting
Implement rate limiting for API endpoints:
function apply_api_rate_limit($result, $server, $request) {
$route = $request->get_route();
// Different limits for different endpoints
$rate_limits = array(
'/wp/v2/posts' => array('limit' => 100, 'window' => 60),
'/wp/v2/users' => array('limit' => 20, 'window' => 60),
'/auth/v1/token' => array('limit' => 5, 'window' => 60),
'default' => array('limit' => 60, 'window' => 60),
);
$config = $rate_limits[$route] ?? $rate_limits['default'];
// Get identifier (API key, user ID, or IP)
$identifier = get_api_identifier($request);
$cache_key = 'api_rate_' . md5($route . $identifier);
$current = get_transient($cache_key) ?: 0;
if ($current >= $config['limit']) {
return new WP_Error(
'rate_limit_exceeded',
'Rate limit exceeded',
array(
'status' => 429,
'headers' => array(
'X-RateLimit-Limit' => $config['limit'],
'X-RateLimit-Remaining' => 0,
'Retry-After' => $config['window'],
),
)
);
}
set_transient($cache_key, $current + 1, $config['window']);
// Add rate limit headers to response
add_filter('rest_post_dispatch', function($response) use ($config, $current) {
$response->header('X-RateLimit-Limit', $config['limit']);
$response->header('X-RateLimit-Remaining', $config['limit'] - $current - 1);
return $response;
});
return $result;
}
add_filter('rest_pre_dispatch', 'apply_api_rate_limit', 10, 3);
Response Data Filtering
Prevent data over-exposure in API responses:
// Remove sensitive fields from user responses
add_filter('rest_prepare_user', function($response, $user, $request) {
$data = $response->get_data();
// Remove fields that should not be exposed
unset($data['registered_date']);
unset($data['capabilities']);
unset($data['extra_capabilities']);
unset($data['avatar_urls']);
// Only include email for authenticated requests
if (!is_user_logged_in() || get_current_user_id() !== $user->ID) {
unset($data['email']);
}
$response->set_data($data);
return $response;
}, 10, 3);
// Limit post fields in listing endpoints
add_filter('rest_prepare_post', function($response, $post, $request) {
// Check if this is a listing request
if ($request->get_param('per_page') !== null) {
$data = $response->get_data();
// Remove heavy fields from listings
unset($data['content']['raw']);
unset($data['excerpt']['raw']);
$response->set_data($data);
}
return $response;
}, 10, 3);
Disabling Unnecessary Endpoints
Remove endpoints you do not need:
add_filter('rest_endpoints', function($endpoints) {
// Remove user enumeration
unset($endpoints['/wp/v2/users']);
unset($endpoints['/wp/v2/users/(?P[\d]+)']);
// Remove comment endpoints if not needed
unset($endpoints['/wp/v2/comments']);
// Remove settings endpoint
unset($endpoints['/wp/v2/settings']);
// Remove block types
unset($endpoints['/wp/v2/block-types']);
return $endpoints;
});
Frontend Security Headers
Add security headers for API responses:
add_filter('rest_post_dispatch', function($response) {
$response->header('X-Content-Type-Options', 'nosniff');
$response->header('X-Frame-Options', 'DENY');
$response->header('Cache-Control', 'no-store, private');
// Content Security Policy for JSON responses
$response->header(
'Content-Security-Policy',
"default-src 'none'; frame-ancestors 'none'"
);
return $response;
});
Webhook Security
Secure outgoing webhooks to frontend:
function send_secure_webhook($event, $data, $endpoint) {
$timestamp = time();
$payload = json_encode(array(
'event' => $event,
'data' => $data,
'timestamp' => $timestamp,
));
// Sign the webhook
$secret = get_option('webhook_secret');
$signature = hash_hmac('sha256', $payload, $secret);
$response = wp_remote_post($endpoint, array(
'body' => $payload,
'headers' => array(
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Timestamp' => $timestamp,
),
'timeout' => 10,
));
// Log webhook delivery
log_webhook_delivery($event, $endpoint, !is_wp_error($response));
return $response;
}
Conclusion
Headless WordPress security requires proper API authentication, CORS configuration, rate limiting, and response filtering. The separation of frontend and backend introduces new attack vectors that need specific protection strategies.
Written by Sarah Chen
WP Folder Shield Team