How to add correct CORS headers to the REST API in PHP in WordPress

Contents

Introduction

This article is a complete, production-ready guide to adding correct CORS (Cross-Origin Resource Sharing) headers to the WordPress REST API using PHP. It explains the required headers, safe defaults, how to handle preflight (OPTIONS) requests, how to do this inside themes or plugins, how to do it server-side (nginx / Apache), and how to test and troubleshoot. Examples are provided for both development (easy) and production (secure) setups.

Why CORS matters for the WordPress REST API

When a web application served from origin A (for example https://app.example.com) makes a fetch() or XMLHttpRequest to a WordPress REST API hosted at origin B (for example https://api.example.com or https://example.com), the browser enforces same-origin policy. That policy requires the server at origin B to explicitly allow the requesting origin via CORS response headers. Without those headers the browser will block the request (you will see errors in DevTools like: Access to fetch at … from origin … has been blocked by CORS policy).

Important CORS headers

  • Access-Control-Allow-Origin: Which origin(s) may access the resource. Use a specific origin value do not use if you plan to allow credentials.
  • Access-Control-Allow-Methods: Allowed HTTP methods for cross-origin requests (GET, POST, OPTIONS, PUT, DELETE, PATCH).
  • Access-Control-Allow-Headers: Headers allowed in the actual request (Authorization, Content-Type, X-WP-Nonce, etc.).
  • Access-Control-Allow-Credentials: If true, allows cookies and other credentials to be sent. This requires a non-wildcard origin value.
  • Access-Control-Max-Age: How long the browser can cache the preflight response (in seconds).
  • Vary: Origin: Important when you return a different Access-Control-Allow-Origin depending on the request Origin — tells caches to keep separate entries for different origin requests.

WordPress specifics

The REST API lives under /wp-json/. To configure CORS for REST requests you have two common options:

  1. Add headers at application layer (functions.php, plugin, or mu-plugin) using WordPress hooks — this is flexible and easy to maintain with WP-specific logic.
  2. Add headers at the server (nginx / Apache) or at CDN/edge — this is often preferred for performance and to ensure headers are sent even for cached responses.

For WordPress, the most robust place to add REST CORS headers in PHP is with the rest_pre_serve_request filter. This filter runs right before the REST API emits the response and is the correct hook to add headers and short-circuit preflight requests if needed.

Simple development-only snippet (allow everything)

Use this only for local development or quick demos. It allows any origin and many headers/methods. Do NOT deploy this to production if your API uses cookies, authentication, or handles confidential data.

// Place into functions.php or a small plugin for development only
add_filter( rest_pre_serve_request, function( served, result, request, server ) {
    header( Access-Control-Allow-Origin:  )
    header( Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS )
    header( Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce )

    // Allow preflight to end early
    if ( OPTIONS === _SERVER[REQUEST_METHOD] ) {
        status_header( 200 )
        exit
    }

    return served
}, 10, 4 )

Secure production-ready implementation

For production, use a whitelist of allowed origins, return that origin exactly (not ), send Vary: Origin, allow credentials only when needed, and reflect the requested headers back in Access-Control-Allow-Headers if appropriate.

/
  Add proper CORS headers to WordPress REST API responses.
  Place this in an mu-plugin or in functions.php of a plugin/theme.
 /
add_filter( rest_pre_serve_request, function( served, result, request, server ) {
    // Configure your allowed origins here
    allowed_origins = array(
        https://app.example.com,
        https://admin.example.com,
    )

    origin = isset( _SERVER[HTTP_ORIGIN] ) ? trim( wp_unslash( _SERVER[HTTP_ORIGIN] ) ) : 

    // Only set a specific origin if its in the allowlist
    if ( origin  in_array( origin, allowed_origins, true ) ) {
        // Use esc_url_raw to ensure the origin header is safe
        header( Access-Control-Allow-Origin:  . esc_url_raw( origin ) )
        // Tell caches that the response varies by Origin
        header( Vary: Origin )
    }

    // If your frontend needs cookies or Authorization headers, allow credentials.
    // Remember: When allowing credentials, Access-Control-Allow-Origin must be explicit.
    header( Access-Control-Allow-Credentials: true )

    // Methods you expect cross-origin clients to use
    header( Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS )

    // If browser sends Access-Control-Request-Headers (preflight), echo it back
    if ( isset( _SERVER[HTTP_ACCESS_CONTROL_REQUEST_HEADERS] ) ) {
        req_headers = sanitize_text_field( wp_unslash( _SERVER[HTTP_ACCESS_CONTROL_REQUEST_HEADERS] ) )
        header( Access-Control-Allow-Headers:  . req_headers )
    } else {
        header( Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce )
    }

    // Cache preflight for 10 minutes
    header( Access-Control-Max-Age: 600 )

    // Handle preflight requests: return early with 200/204
    if ( OPTIONS === _SERVER[REQUEST_METHOD] ) {
        // Some clients expect 204 (No Content) or 200 200 is safe
        status_header( 200 )
        exit
    }

    return served
}, 10, 4 )

Notes on the production snippet

  • Whitelist origins: Keep the allowlist small. Use fully-qualified origins including scheme.
  • Vary: Origin: Ensures caches (CDN, browser) store separate responses per origin when you return different Access-Control-Allow-Origin values.
  • Credentials: If your SPA uses cookies or you use cookie-based auth, set Access-Control-Allow-Credentials to true and never use for the origin.
  • Headers whitelist: Allow the headers your client sends (Authorization, X-WP-Nonce for non-logged-in nonce usage, Content-Type, etc.).
  • Preflight: Browsers send OPTIONS preflight for certain requests. Respond with a short-circuited 200/204 and include the headers above.

Handling X-WP-Nonce and nonces credentials

If you use wp_localize_script or wp_create_nonce and send X-WP-Nonce with requests from a browser, the browser will include that header and your server must return it in Access-Control-Allow-Headers for the preflight to succeed. If you also use cookies for authentication (e.g., normal logged-in sessions), you must set Access-Control-Allow-Credentials: true and use an explicit origin in Access-Control-Allow-Origin.

Server-level examples (recommended for caches / CDNs)

If your REST responses are served by a CDN or cached by Fastly / Cloudflare or a server-level cache, adding headers at the application level may sometimes be stripped or not applied consistently. Its frequently better to add CORS headers at the server / CDN edge. Examples follow.

# nginx example: add to the server{} block or a location for /wp-json/
location ~ ^/wp-json/ {
    if (request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin https://app.example.com
        add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE
        add_header Access-Control-Allow-Headers Authorization,Content-Type,X-WP-Nonce
        add_header Access-Control-Allow-Credentials true
        add_header Access-Control-Max-Age 600
        add_header Vary Origin
        return 204
    }

    add_header Access-Control-Allow-Origin https://app.example.com
    add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE
    add_header Access-Control-Allow-Headers Authorization,Content-Type,X-WP-Nonce
    add_header Access-Control-Allow-Credentials true
    add_header Vary Origin

    try_files uri uri/ /index.php?args
}
# Apache example for Apache 2.4  add to virtual host or .htaccess if allowed

    
        Header always set Access-Control-Allow-Origin https://app.example.com
        Header always set Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE
        Header always set Access-Control-Allow-Headers Authorization, Content-Type, X-WP-Nonce
        Header always set Access-Control-Allow-Credentials true
        Header always set Access-Control-Max-Age 600
        Header always set Vary Origin
    

Testing CORS behavior with curl

Use curl to simulate both a preflight (OPTIONS) and a simple GET with an Origin header. These examples show the response headers included in the output.

# Preflight OPTIONS test
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

# Simple GET test
curl -i https://example.com/wp-json/wp/v2/posts 
  -H Origin: https://app.example.com

Common pitfalls and debugging

  • No Access-Control-Allow-Origin header in response: Confirm your PHP code ran. Use curl without browser caching to inspect raw responses. Check server-level rules (nginx/apache) that might override or strip PHP-set headers. Confirm that your filter is attached (function is loaded) — mu-plugins run earlier than normal plugins and are a reliable place for CORS settings.
  • Wildcards and credentials: If you set Access-Control-Allow-Credentials: true, browsers will reject as the value of Access-Control-Allow-Origin. You must return an explicit origin string.
  • Cached preflight responses: CDNs and caches can cache CORS headers incorrectly. Use Vary: Origin and be careful with caching rules for /wp-json/ endpoints.
  • Plugins or security tools: Security plugins or server modules may modify or remove headers. Test with those disabled if possible to isolate the issue.
  • Mismatch of request headers: If the browser requests non-simple headers (Authorization, X-WP-Nonce), you must include those in Access-Control-Allow-Headers (or echo back requested headers) or the preflight will fail.

Troubleshooting checklist

  1. Use curl with an Origin header to inspect raw response headers.
  2. Confirm your PHP snippet is active (temporarily add an error_log or die() call to validate load order, then remove the diagnostic).
  3. Verify server-level (nginx/Apache/CDN) rules are not overriding or removing the header.
  4. Check browser console for specific CORS errors (missing header, wildcard with credentials, etc.).
  5. Ensure Vary: Origin is sent if returning different origins per request.
  6. If using credentials, ensure cookies and Authorization flows are correct and that the Origin header matches a permitted origin exactly.

Security recommendations

  • Never use Access-Control-Allow-Origin: with Access-Control-Allow-Credentials: true.
  • Whitelist only the origins you need — avoid opening the API to arbitrary third parties.
  • Limit Access-Control-Allow-Methods to the minimal set required by your clients.
  • Limit Access-Control-Allow-Headers to expected headers avoid permitting arbitrary headers unless needed.
  • Prefer server-level header configuration for high-traffic sites and when using CDNs so headers are consistent at the edge.

Summary

CORS for the WordPress REST API is straightforward once you understand the required headers, preflight mechanics, and the need to be explicit about origins when credentials are involved. The recommended approach is to either add headers with the rest_pre_serve_request filter for WordPress-aware behavior or to configure headers at the server/CDN edge for performance and consistency. Always prefer a whitelist approach in production and test thoroughly (curl browser devtools) to confirm correct behavior.

Useful references



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *