Browser developer tools Inspector and Console panel showing HTML and DOM structure, representing browser-side debugging of CORS errors

WordPress REST API CORS Errors: Browser Fix Guide

Your curl request to /wp-json/wp/v2/posts returns a clean 200 with JSON. You paste the same URL into your frontend JavaScript, and the browser console throws a CORS error. The request never fails at the server. It fails at the browser. Cross-Origin Resource Sharing (CORS) is a browser-only security mechanism, and understanding exactly how WordPress handles it saves hours of misdirected debugging.

This guide covers the full picture: what WordPress sends by default, why preflight OPTIONS requests fail, how to customize CORS headers safely with working code, and the security implications of relaxing CORS. If you are troubleshooting broader REST API connectivity issues, our REST API disabled troubleshooting guide covers the server-side causes that CORS cannot explain.

Why It Works in curl but Fails in the Browser

HTTP spelled with white keyboard keys on a pink background, representing HTTP requests and cross-origin resource sharing

CORS is enforced by the browser, not the server. When you make a request from curl, Postman, or any server-side HTTP client, there is no CORS check. The request goes out, the server responds, and you get the response. The browser enforces CORS because JavaScript running on https://app.example.com making a request to https://wp.example.com is a cross-origin request, and the browser blocks the response unless the server explicitly allows it.

The key distinction: the server processes the request and sends the response. The browser receives the response, inspects the headers, and decides whether to hand the response to your JavaScript or block it. If the response lacks the right Access-Control-Allow-Origin header, the browser discards it and throws a CORS error in the console. Your server logs show a successful 200 response. The browser never shows it to your code.

This is why copying a curl command into a browser fetch call “breaks.” The request was never broken. The browser is doing exactly what it was designed to do: protecting users from scripts on one origin reading responses from another origin without permission.

One common point of confusion: CORS is not authentication. CORS does not verify who is making the request. It controls whether a browser context is allowed to read a response. A server-side script can still make any request it wants to your REST API, CORS headers or not. If you are debugging authentication failures specifically, see our guide on WordPress application passwords not working for the server-side auth layer.

What WordPress Sends by Default

WordPress core sends permissive CORS headers by default through the rest_send_cors_headers() function, hooked into the rest_pre_serve_request filter. The function reads the incoming Origin header and reflects it back as Access-Control-Allow-Origin. It also sends Access-Control-Allow-Credentials: true, which permits cookie-based authenticated requests from cross-origin browsers.

This is an intentional design decision. WordPress uses nonces for CSRF protection rather than relying on CORS origin checking. The REST API does not verify the Origin header against an allowlist. Any origin that makes a request gets its own origin reflected back in the response. For most setups, this means CORS “just works” out of the box, and browser-based requests to the REST API succeed without configuration.

If you are seeing CORS errors despite WordPress’s default behavior, something has overridden or removed the default headers. The most common culprits are security plugins, custom theme code, or a must-use plugin that unhooked rest_send_cors_headers. Search your codebase:

grep -r "rest_send_cors_headers" wp-content/

If the grep finds a remove_filter call, that is your cause. If it finds nothing, a security plugin may be intercepting the request before WordPress sends CORS headers, or a server-level configuration may be stripping response headers before they reach the browser.

Preflight OPTIONS Requests and Why They Fail

For “simple” requests (GET with no custom headers, POST with standard form content types), the browser sends the request directly and checks the response headers. For anything more complex, the browser sends a preflight OPTIONS request first. A preflight is the browser asking the server: “Is this method, these headers, and this origin acceptable?” before sending the actual request.

Preflight requests are triggered when your JavaScript sends:

  • Custom headers like Authorization or X-WP-Nonce
  • Methods other than GET, HEAD, or POST (such as PUT, PATCH, or DELETE)
  • A Content-Type other than application/x-www-form-urlencoded, multipart/form-data, or text/plain (so application/json triggers preflight)

The preflight OPTIONS request must return Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers in its response. If the server does not respond correctly to the OPTIONS request, the browser never sends the actual request. You see a CORS error, but the real failure is the OPTIONS request being blocked.

The most common cause: a security plugin or server firewall blocks the OPTIONS method entirely. Wordfence, ModSecurity rules, or even Nginx configurations can reject OPTIONS requests as suspicious because they do not carry authentication tokens or request bodies, which looks anomalous to WAF heuristics.

To diagnose, send the preflight request manually with curl:

curl -i -X OPTIONS https://example.com/wp-json/wp/v2/posts
-H "Origin: https://app.example.com"
-H "Access-Control-Request-Method: POST"
-H "Access-Control-Request-Headers: Authorization, Content-Type"

If the response is a 403 or 405, something is blocking the OPTIONS method before WordPress handles it. Check your security plugin settings for method restrictions, server config for limit_except directives, or WAF logs for blocked OPTIONS requests. WordPress itself handles OPTIONS requests correctly when the request reaches the REST API bootstrap. The block happens upstream.

Fixing It: rest_send_cors_headers

PHP code displayed on a dark monitor screen showing WordPress filter functions and server-side programming

The rest_pre_serve_request filter fires just before WordPress sends the REST API response. The rest_send_cors_headers() function is hooked to it by default and sends the permissive CORS headers described above. If something removed that hook, or if you need to customize the CORS headers to restrict access to specific origins, you hook into the same filter.

The simplest fix, if the default function was removed, is to re-add it:

add_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );

For stricter control, where you want to allow only specific trusted origins, remove the default function and replace it with a custom callback. Here is working code for the common case of allowing a single trusted origin. Add this to a must-use plugin (recommended) or your theme’s functions.php:

// Remove default permissive CORS headers
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );

add_filter( 'rest_pre_serve_request', function( $value ) {
    $allowed_origins = array(
        'https://app.example.com',
        'https://dashboard.example.com',
    );

    $origin = get_http_origin();

    if ( $origin && in_array( $origin, $allowed_origins, true ) ) {
        header( 'Access-Control-Allow-Origin: ' . $origin );
        header( 'Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETE' );
        header( 'Access-Control-Allow-Credentials: true' );
        header( 'Access-Control-Allow-Headers: Authorization, Content-Type' );
        header( 'Vary: Origin', false );
    }

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

    return $value;
} );

The Vary: Origin header is important for caching. Without it, a CDN or reverse proxy may cache the response with one origin’s CORS headers and serve it to a different origin, breaking CORS for that second origin. The false parameter prevents PHP from replacing an existing Vary header, allowing it to append.

The OPTIONS handling at the end short-circuits preflight requests after the headers are sent. WordPress core handles this in newer versions, but if a plugin or server config interferes, explicitly exiting after sending headers ensures the preflight response reaches the browser cleanly.

Credentials and Why Wildcards Break Auth

When your JavaScript sends authenticated requests to the WordPress REST API, you need Access-Control-Allow-Credentials: true in the response. This tells the browser it is allowed to send cookies (including the WordPress logged-in cookie and nonce) with the cross-origin request. Without it, the browser strips credentials from the request, and WordPress sees an anonymous, unauthenticated visitor.

Here is the catch: the CORS spec forbids using Access-Control-Allow-Origin: * (the wildcard) when credentials are involved. If the server sends Access-Control-Allow-Credentials: true alongside Access-Control-Allow-Origin: *, the browser rejects the response. This is a hard rule in the Fetch specification, not a WordPress-specific behavior.

The Wildcard Trap

This is where people get stuck. They read that WordPress sends permissive CORS headers, assume it sends a wildcard origin, and try to combine it with credentials. WordPress does not send a wildcard. It reflects the specific incoming Origin header back as the value of Access-Control-Allow-Origin. This satisfies the spec because the origin is specific, not a wildcard, and it works with credentials.

If you are writing custom CORS code, never do this:

// BROKEN: wildcard origin with credentials
header( 'Access-Control-Allow-Origin: *' );
header( 'Access-Control-Allow-Credentials: true' );

Instead, reflect the origin if it is in your allowlist, as shown in the code above. This is the correct pattern for authenticated cross-origin requests.

Application Passwords vs. Cookie Auth

If you are using application passwords (Basic Auth via the Authorization header) instead of cookie authentication, you do not strictly need Access-Control-Allow-Credentials: true because Basic Auth headers are not cookies. However, the browser still requires the Access-Control-Allow-Headers response header to include Authorization, or the preflight request will fail. The custom code above includes this header.

The Security Tradeoff, Stated Plainly

Laptop displaying a security lock icon and Secured status on screen, representing web security and CORS access control

Relaxing CORS headers is opening a door. The default WordPress behavior of reflecting any origin is permissive by design, because WordPress relies on nonces and authentication rather than origin restrictions. If you restrict CORS to specific origins, you are adding a layer of defense. If you open it to all origins with credentials, you are removing one.

The real risks are not hypothetical. A malicious script on a compromised or attacker-controlled website can make requests to your WordPress REST API from a victim’s browser. If the victim is logged into your WordPress site and CORS allows credentials from any origin, the attacker’s script can make authenticated requests using the victim’s session. WordPress nonces mitigate this for cookie-based requests, but nonce generation itself is an authenticated endpoint, which creates a circular dependency if you open it too widely.

The practical guidance:

  • Allow specific origins only. The allowlist pattern in the code above is the right approach for production. Hardcode the domains that need access.
  • Avoid reflecting arbitrary origins. The default WordPress behavior reflects any origin. This works because WordPress uses nonces, but it is more permissive than necessary for most sites.
  • Do not disable nonce verification. Some developers disable nonce checks to simplify API integration. This removes the CSRF protection that makes WordPress’s permissive CORS safe.
  • Use application passwords for server-to-server integrations. If your integration is server-side, CORS does not apply. Use application passwords over HTTPS and skip browser-based auth entirely.

The honest tradeoff: stricter CORS headers add security but require maintenance when origins change. Permissive headers reduce friction but increase attack surface. For a personal blog with no authenticated REST usage, the defaults are fine. For a membership site or e-commerce store with authenticated REST endpoints, restrict origins explicitly.

CORS ConfigurationSecurity LevelWhen to Use
Default (reflect any origin)Permissive, relies on noncesSimple sites, no sensitive API surface
Specific origin allowlistRestricted, recommendedProduction sites with known frontend origins
Wildcard with credentialsBroken (spec violation)Never. Browser rejects this combination
No CORS headersMaximum restrictionServer-to-server only, no browser access needed

If you are automating WordPress content publishing and want to avoid the CORS configuration rabbit hole entirely, ClearPost handles the full pipeline server-side. The plugin authenticates using WordPress application passwords and manages post creation, featured images, SEO metadata, and taxonomy assignment. No browser-based CORS configuration, no manual header debugging. You approve every post before it goes live.

Try ClearPost free for 7 days. AI does the heavy lifting, you approve every post before it goes live. No long onboarding, no agency overhead, cancel anytime.

Frequently Asked Questions

Why does my WordPress REST API request work in curl but fail with a CORS error in the browser?

CORS is enforced by the browser, not the server. curl sends the request and receives the response directly. The browser sends the request, receives the response, then checks for Access-Control-Allow-Origin in the response headers. If the header is missing or does not match the request origin, the browser blocks the response from reaching your JavaScript. The server processed the request successfully, but the browser refused to hand the response to your code.

What CORS headers does WordPress send by default?

WordPress core hooks rest_send_cors_headers into the rest_pre_serve_request filter. This function reads the incoming Origin header and reflects it back as Access-Control-Allow-Origin. It also sends Access-Control-Allow-Credentials: true. This is permissive by design because WordPress uses nonces for CSRF protection rather than origin restrictions.

Why does Access-Control-Allow-Origin: * break authenticated requests?

The Fetch specification forbids combining a wildcard origin with Access-Control-Allow-Credentials: true. The browser rejects the response entirely. Instead of a wildcard, you must reflect the specific origin from the request, which satisfies the spec and works with credentials. WordPress does this by default by reflecting the incoming Origin header.

How do I fix preflight OPTIONS requests failing on my WordPress REST API?

Preflight OPTIONS requests are triggered by custom headers like Authorization, methods like PUT or DELETE, or JSON content types. If a security plugin, WAF, or server config blocks the OPTIONS method, the preflight fails and the browser never sends the actual request. Test with curl -X OPTIONS to your endpoint with the appropriate headers. If you get a 403 or 405, check security plugin settings and server configuration for OPTIONS method restrictions.

How do I restrict WordPress REST API CORS to specific origins?

Remove the default rest_send_cors_headers filter and add a custom callback to rest_pre_serve_request. In your callback, check the incoming origin against an allowlist array using get_http_origin(). If it matches, send Access-Control-Allow-Origin with the specific origin, along with Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Allow-Credentials. Include a Vary: Origin header for correct caching behavior.