How to handle CORS preflight OPTIONS requests from your WordPress Plugin

Published on August 13, 2021 • 4m read

Browsers send a preflight OPTIONS request to the server when doing Cross-Origin Resource Sharing. Handle that with caching for WordPress plugins.

Handling CORS preflight OPTIONS request from WordPress PHP

Image by Domenic Hoffmann from Pixabay.

Cross-Origin Request Sharing or CORS is often the thing where we encounter the famous error Cross-Origin Request Blocked. In this blog post, we will learn what it is and how we can securely remedy that. At the end of this post, I promise that your application will run on all browsers, including localhost on Chrome.

This post will concentrate on an imaginary WordPress Plugin Acme Preflight but the concepts will be same for any server (nodejs, rails or which ever you are using).

TLDR VERSION

When responding to the request, make sure you are sending proper Access-Control headers and handling the OPTIONS request method. In PHP, the code will look something like this, for PHP or WordPress plugins.

[php]myplugin.php
1// preset option for allowed origins for our API server
2$allowed_origins = [
3 'https://www.wpeform.io',
4 'https://app.wpeform.io',
5];
6$request_origin = isset( $_SERVER['HTTP_ORIGIN'] )
7 ? $_SERVER['HTTP_ORIGIN']
8 : null;
9// if there is no HTTP_ORIGIN, then bail
10if ( ! $request_origin ) {
11 return;
12}
13
14// a fallback value for allowed_origin we will send to the response header
15$allowed_origin = 'https://www.wpeform.io';
16
17// now determine if request is coming from allowed ones
18if ( in_array( $request_origin, $allowed_origins ) ) {
19 $allowed_origin = $request_origin;
20}
21
22// print needed allowed origins
23header( "Access-Control-Allow-Origin: {$allowed_origin}" );
24header( 'Access-Control-Allow-Credentials: true' );
25header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
26
27// chrome and some other browser sends a preflight check with OPTIONS
28// if that is found, then we need to send response that it's okay
29// @link https://stackoverflow.com/a/17125550/2754557
30if (
31 isset( $_SERVER['REQUEST_METHOD'] )
32 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'
33) {
34 // need preflight here
35 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );
36 // add cache control for preflight cache
37 // @link https://httptoolkit.tech/blog/cache-your-cors/
38 header( 'Access-Control-Max-Age: 86400' );
39 header( 'Cache-Control: public, max-age=86400' );
40 header( 'Vary: origin' );
41 // just exit and CORS request will be okay
42 // NOTE: We are exiting only when the OPTIONS preflight request is made
43 // because the pre-flight only checks for response header and HTTP status code.
44 exit( 0 );
45}
46// continue with your app

Copied!


Now let us see what CORS is, what preflight is and how we are supposed to handle that.

What is a CORS

Quoting from MDN

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading of resources.

Now don't worry if it doesn't make much sense. Let me try to simplify a use-case.

  1. Let's say you are developing a WordPress Plugin Acme Preflight where you are providing an API to search for all pre-flight services.
  2. You've hooked into WordPress' Rewrite API to provide a simple JSON API server at https://yoursite.com/acme-preflight/api/ URL.
  3. Your JavaScript app is supposed to send a GET/POST request on the API endpoint and in return the server would send some JSON data.

The JS code may look something like this

[js]
1function getPreflights() {
2 fetch('https://yoursite.com/acme-preflight/api/')
3 .then(res => res.json())
4 .then(data => {
5 // do something with the data, perhaps create beautiful UI
6 console.log(data);
7 })
8 .error(err => {
9 console.log(err);
10 });
11}

Copied!

Pretty standard stuff. You've coded all needed WordPress actions and filters. When you are opening the page, you are seeing the output. You've even created a Shortcode or perhaps a Block where you print the JavaScript which makes the fetch call and it works all good.

Now you want to make a standalone app version at https://preflight.yoursite.com where you've put the same JavaScript code and it should work. Instead, you get the following error:

[plain]
1Cross-Origin Request Blocked:
2The Same Origin Policy disallows reading the remote resource at $somesite

Copied!

Welcome to the world of CORS. Let's see what is happening that causes the error.

  1. Your browser knows that you are at the website https://preflight.yoursite.com.
  2. Your browser sees the JavaScript code at this website is making a request to https://yoursite.com.
  3. From browser's point of view, https://yoursite.com and https://preflight.yoursite.com are different resources.
  4. Browser sends a preflight request (a HTTP OPTIONS request) to https://yoursite.com/acme-preflight/api/.
  5. Your server is not handling the preflight request.
  6. The browser therefore thinks the API server does not allow sending requests from any domain other than its own.
  7. So JavaScript is blocked from fetching.

and that gives you the above error. It really is as simple as that. So how do we solve this?

We've to explicitly tell the browser from our API server https://yoursite.com that it is OKAY for https://preflight.yoursite.com to send requests.

Sending Access Control headers to allow CORS

Let's take a look at our handler function for the API server. It could be something like this.

[php]
1function acme_preflight_api() {
2 // get data from the database
3 $data = get_option( 'acme_preflight_data', null );
4 // send JSON response
5 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
6 echo json_encode( $data );
7 // die to prevent further output
8 die();
9}

Copied!

We have to do a few more things here.

  1. Send necessary Access-Control headers to tell the browser that it is okay to send request from domains other than the source.
  2. Check for preflight requests, basically HTTP OPTIONS request.
  3. Set proper Cache-Control headers to prevent the browser from sending preflight requests on every instance.

Set Access Control headers for CORS

First we have to send headers saying https://preflight.yoursite.com can send a request to our API server. This is very simple.

[php]
1header( "Access-Control-Allow-Origin: https://preflight.yoursite.com" );
2header( 'Access-Control-Allow-Credentials: true' );
3header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );

Copied!

  • Access-Control-Allow-Origin - The base URL of the website from where we expect requests. Do not include any trailing slash or path after the domain. It needs to match exactly, the protocol, subdomain, domain and tld. https://preflight.yoursite.com is NOT http://preflight.yoursite.com.
  • Access-Control-Allow-Credentials - If set to true, then JavaScript code can send and receive credentials, like cookies, authorization headers etc. Read more at mdn . This is useful when you have cookie based authentication across domain.
  • Access-Control-Allow-Methods - HTTP methods our server would accept and handle. Here we must specify the OPTIONS method.

But what if we intend to publish our JavaScript app on more than one domain? How do we handle Access-Control-Allow-Origin then? The answer is, we check against allowed set of domains. Here's a more complete code within our handler function.

[php]
1// preset option for allowed origins for our API server
2$allowed_origins = [
3 'https://yoursite.com',
4 'https://preflight.yoursite.com',
5 'https://app.yoursite.com',
6];
7$request_origin = isset( $_SERVER['HTTP_ORIGIN'] )
8 ? $_SERVER['HTTP_ORIGIN']
9 : null;
10// if there is no HTTP_ORIGIN, then set current site URL
11if ( ! $request_origin ) {
12 $request_origin = site_url( '' );
13}
14
15// a fallback value for allowed_origin we will send to the response header
16$allowed_origin = 'https://yoursite.com';
17
18// now determine if request is coming from allowed ones
19if ( in_array( $request_origin, $allowed_origins ) ) {
20 $allowed_origin = $request_origin;
21}
22
23// print needed allowed origins
24header( "Access-Control-Allow-Origin: {$allowed_origin}" );
25header( 'Access-Control-Allow-Credentials: true' );
26header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );

Copied!

Now if you try to run your JavaScript code, it will still fail. There is one more thing we need to do.

Handle CORS preflight OPTIONS request

Before actually sending the fetch request, the browser sends a preflight request to the same API endpoint.

If you take a look at the Chrome DevTool Network Tab, then you will find two requests to the API server, one marked Preflight. This is where the browser determines if it is okay to send the actual request.

Chrome DevTool Preflight
Chrome DevTool Network Tab

The request looks something like this:

[plain]
1OPTIONS /acme-preflight/api/
2Access-Control-Request-Method: GET
3Access-Control-Request-Headers: origin, content-type
4Origin: https://foo.bar.org

Copied!

Based on this request, if our API servers sends a response with HTTP 200 and proper Access Control headers, the browser will continue with the actual request. A response from the server may look like this.

[plain]
1HTTP/1.1 204 No Content
2Connection: keep-alive
3Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept
4Access-Control-Allow-Origin: https://preflight.yoursite.com
5Access-Control-Allow-Methods: POST, GET, OPTIONS
6Access-Control-Max-Age: 86400

Copied!

Now we write the PHP code responsible for that.

[php]
1// print needed allowed origins
2header( "Access-Control-Allow-Origin: {$allowed_origin}" );
3header( 'Access-Control-Allow-Credentials: true' );
4header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
5
6// if this is a preflight request
7if (
8 isset( $_SERVER['REQUEST_METHOD'] )
9 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'
10) {
11 // need preflight here
12 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );
13 // just exit and CORS request will be okay
14 // NOTE: We are exiting only when the OPTIONS preflight request is made
15 // because the pre-flight only checks for response header and HTTP status code.
16 exit( 0 );
17}

Copied!

Now if you try to run your JavaScript app, it should just work.

Setting cache or max age in preflight response

If you notice really carefully, then you will find that everytime we send a fetch request to our API endpoint, browser sends a preflight request before it. It unnecessarily slows down API responses. Ideally the preflight response should be cached and shouldn't send more than the first time.

By default, browsers cache the preflight response for 5 seconds. So any request made after that, would mean resending the preflight again. But luckily this can be altered by sending a Access-Control-Max-Age response header. The actual cache value will vary, but according to mdn this is the general rule.

  • Firefox caps this at 24 hours (86400 seconds).
  • Chromium (prior to v76) caps at 10 minutes (600 seconds).
  • Chromium (starting in v76) caps at 2 hours (7200 seconds).
  • Chromium also specifies a default value of 5 seconds.
  • A value of -1 will disable caching, requiring a preflight OPTIONS check for all calls.

So we modify our code to include the needed header.

[php]
1// print needed allowed origins
2header( "Access-Control-Allow-Origin: {$allowed_origin}" );
3header( 'Access-Control-Allow-Credentials: true' );
4header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
5
6// if this is a preflight request
7if (
8 isset( $_SERVER['REQUEST_METHOD'] )
9 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'
10) {
11 // need preflight here
12 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );
13 // add cache control for preflight cache
14 // @link https://httptoolkit.tech/blog/cache-your-cors/
15 header( 'Access-Control-Max-Age: 86400' );
16 header( 'Cache-Control: public, max-age=86400' );
17 header( 'Vary: origin' );
18 // just exit and CORS request will be okay
19 // NOTE: We are exiting only when the OPTIONS preflight request is made
20 // because the pre-flight only checks for response header and HTTP status code.
21 exit( 0 );
22}

Copied!

Implementing CORS in the WordPress Plugin

So to wrap up, the final version of our acme_preflight_api function may look something like this:

[php]
1function acme_preflight_api() {
2 // preset option for allowed origins for our API server
3 $allowed_origins = [
4 'https://yoursite.com',
5 'https://preflight.yoursite.com',
6 'https://app.yoursite.com',
7 ];
8 $request_origin = isset( $_SERVER['HTTP_ORIGIN'] )
9 ? $_SERVER['HTTP_ORIGIN']
10 : null;
11 // if there is no HTTP_ORIGIN, then set current site URL
12 if ( ! $request_origin ) {
13 $request_origin = site_url( '' );
14 }
15 // a fallback value for allowed_origin we will send to the response header
16 $allowed_origin = 'https://yoursite.com';
17 // now determine if request is coming from allowed ones
18 if ( in_array( $request_origin, $allowed_origins ) ) {
19 $allowed_origin = $request_origin;
20 }
21
22 // print needed allowed origins
23 header( "Access-Control-Allow-Origin: {$allowed_origin}" );
24 header( 'Access-Control-Allow-Credentials: true' );
25 header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
26
27 // if this is a preflight request
28 if (
29 isset( $_SERVER['REQUEST_METHOD'] )
30 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'
31 ) {
32 // need preflight here
33 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );
34 // add cache control for preflight cache
35 // @link https://httptoolkit.tech/blog/cache-your-cors/
36 header( 'Access-Control-Max-Age: 86400' );
37 header( 'Cache-Control: public, max-age=86400' );
38 header( 'Vary: origin' );
39 // just exit and CORS request will be okay
40 // NOTE: We are exiting only when the OPTIONS preflight request is made
41 // because the pre-flight only checks for response header and HTTP status code.
42 exit( 0 );
43 }
44
45 // get data from the database
46 $data = get_option( 'acme_preflight_data', null );
47 // send JSON response
48 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
49 echo json_encode( $data );
50 // die to prevent further output
51 die();
52}

Copied!


That was a lot of code, but IMHO, these are all needed to make sure the API server works with CORS. If you found this useful, please give a shoutout. If still in doubt, come find me on twitter and we can discuss.

See you next time!

Start building beautiful forms!

Take the next step and get started with WPEForm today. You have the option to start with the free version, or get started with a trial. All your purchases are covered under 30 days Money Back Guarantee.