Most of this course has been server-to-server: a Next.js server, an edge function, or a mobile app talking to WordPress with a credential that never touches an untrusted browser context. But some headless architectures do have a browser making a request directly to WordPress, a client-side widget calling a public, read-only ability from a different domain, for instance. That’s exactly when CORS (cross-origin resource sharing) becomes a real WordPress-side concern, and it’s also a good moment to revisit rate limiting, this time for external callers specifically rather than the internal agent scenario Course 4 covered.
Course 4’s rate limiting lesson (the transient-based counter pattern), and a public
ability exposed via meta.mcp.public or a custom REST route.
Step 1: When CORS is actually your problem
CORS is a browser-enforced restriction, it only applies when JavaScript running in a
browser tab tries to call a different origin than the page it’s on. Every server-side
pattern in this course, the Next.js Route Handler, the edge function, the mobile app,
never triggers CORS at all, because none of them are a browser making a cross-origin
fetch(). CORS becomes relevant only in the specific case where a browser-side script
on https://app.example calls WordPress directly at https://your-site.example.
Step 2: Real CORS handling with rest_pre_serve_request
WordPress’s REST API already has a filter built for adding response headers before a
request is served, rest_pre_serve_request. This is the real, existing mechanism, not
a plugin you need to install:
// File: my-plugin.php
add_filter( 'rest_pre_serve_request', function ( $served, $result, $request ) {
$allowed_origins = array(
'https://app.example',
'https://staging.app.example',
);
$origin = $request->get_header( 'origin' );
if ( $origin && in_array( $origin, $allowed_origins, true ) ) {
header( 'Access-Control-Allow-Origin: ' . $origin );
header( 'Access-Control-Allow-Methods: GET, POST' );
header( 'Access-Control-Allow-Headers: Authorization, Content-Type' );
header( 'Vary: Origin' );
}
return $served;
}, 10, 3 );
The Vary: Origin header matters here too, it tells any caching layer in front of
WordPress that the response depends on the request’s Origin header, so a cached
response for one allowed origin doesn’t get served to a different one.
Step 3: An explicit allowlist, not a wildcard
A wildcard Access-Control-Allow-Origin: * is tempting because it’s simple, and it’s
the wrong default for anything beyond a fully public, read-only, uncredentialed
endpoint. It grants any website on the internet permission to have its visitors’ browsers
call your WordPress endpoint. Compare that with the explicit allowlist above, where only
origins you named can receive the CORS headers that let a browser’s response actually
be read by the calling script.
Browsers already refuse to combine Access-Control-Allow-Origin: * with credentialed
requests (cookies, and generally Basic Auth used this way), so a wildcard with
Application Password Basic Auth attached typically just fails outright rather than
quietly working insecurely. But don’t rely on that browser behavior as your security
boundary, restrict the origin allowlist deliberately regardless, since the failure mode
you actually want is “explicitly refused,” not “accidentally still works.”
Step 4: Rate limiting external callers, reusing the transient pattern
Course 4 built a transient-based counter keyed by the logged-in user’s ID. An external, browser-context caller might not always resolve to one consistent authenticated user (a public, unauthenticated read-only ability, for example), so bucket by IP address or an API key instead, same underlying mechanism:
// File: my-plugin.php
function my_plugin_check_and_increment_external_rate_limit(
string $bucket_key,
int $limit,
int $window_seconds
): bool {
$window = floor( time() / $window_seconds );
$transient_key = "my_plugin_ext_rl_{$bucket_key}_{$window}";
$count = (int) get_transient( $transient_key );
if ( $count >= $limit ) {
return false;
}
set_transient( $transient_key, $count + 1, $window_seconds );
return true;
}
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'my-plugin/get-public-post-summary',
array(
// ...label, schemas...
'permission_callback' => function ( $request ) {
$ip = $request->get_header( 'x-forwarded-for' ) ?: $_SERVER['REMOTE_ADDR'];
$allowed = my_plugin_check_and_increment_external_rate_limit(
'public_summary_' . md5( $ip ),
30, // 30 calls
60 // per 60-second window
);
if ( ! $allowed ) {
return new WP_Error(
'rate_limited',
'Too many requests from this client. Try again shortly.',
array( 'status' => 429 )
);
}
return true;
},
'execute_callback' => function () {
// summary logic
},
)
);
} );
Same counting mechanism as Course 4, a different bucketing key, because an external, possibly unauthenticated caller doesn’t have a stable WordPress user ID to key off of the way an internal agent connection does.
X-Forwarded-For can be set by the client itself unless your infrastructure (a CDN or
reverse proxy you control) is what’s actually setting it after stripping any
client-supplied value. If you’re not certain your hosting setup does this, verify it,
otherwise a caller can trivially claim a different IP on every request and sidestep the
per-IP bucket entirely.
Test it: confirm both protections independently
For CORS, use curl to send a request with a disallowed Origin header and confirm no
Access-Control-Allow-Origin header comes back:
curl -i -H "Origin: https://not-allowed.example" \
"https://your-site.example/wp-json/mcp/v1/my-plugin/get-public-post-summary"
For rate limiting, call the same public ability more than its configured limit in quick
succession and confirm the requests past the limit return 429:
for i in $(seq 1 35); do
curl -s -o /dev/null -w "%{http_code}\n" \
"https://your-site.example/wp-json/mcp/v1/my-plugin/get-public-post-summary"
done
You should see 200 for the first 30 requests and 429 afterward, within the same
60-second window.
Recap
CORS only matters when a browser, not a server, calls WordPress directly from a
different origin, and rest_pre_serve_request is the real, existing WordPress REST API
filter for adding the necessary headers, with an explicit origin allowlist rather than a
wildcard. Rate limiting external callers reuses the exact transient-based counter
pattern from Course 4, just bucketed by IP or API key instead of a logged-in user ID,
since an external caller may not always resolve to one. Together with everything from
the earlier lessons, authentication, typed clients, mobile storage, and edge secrets,
this closes out the security side of serving WordPress as a backend to any front end.
Resources & further reading
- Adding custom REST API endpoints, developer.wordpress.org
- WordPress REST API handbook, developer.wordpress.org
- Transients API reference, developer.wordpress.org