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.
1// preset option for allowed origins for our API server2$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 bail10if ( ! $request_origin ) {11 return;12}1314// a fallback value for allowed_origin we will send to the response header15$allowed_origin = 'https://www.wpeform.io';1617// now determine if request is coming from allowed ones18if ( in_array( $request_origin, $allowed_origins ) ) {19 $allowed_origin = $request_origin;20}2122// print needed allowed origins23header( "Access-Control-Allow-Origin: {$allowed_origin}" );24header( 'Access-Control-Allow-Credentials: true' );25header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );2627// chrome and some other browser sends a preflight check with OPTIONS28// if that is found, then we need to send response that it's okay29// @link https://stackoverflow.com/a/17125550/275455730if (31 isset( $_SERVER['REQUEST_METHOD'] )32 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'33) {34 // need preflight here35 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );36 // add cache control for preflight cache37 // @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 okay42 // NOTE: We are exiting only when the OPTIONS preflight request is made43 // 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.
- Let's say you are developing a WordPress Plugin Acme Preflight where you are providing an API to search for all pre-flight services.
- You've hooked into WordPress' Rewrite API to provide a simple JSON API server at
https://yoursite.com/acme-preflight/api/
URL. - 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
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 UI6 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:
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.
- Your browser knows that you are at the website
https://preflight.yoursite.com
. - Your browser sees the JavaScript code at this website is making a request to
https://yoursite.com
. - From browser's point of view,
https://yoursite.com
andhttps://preflight.yoursite.com
are different resources. - Browser sends a preflight request (a HTTP OPTIONS request) to
https://yoursite.com/acme-preflight/api/
. - Your server is not handling the preflight request.
- The browser therefore thinks the API server does not allow sending requests from any domain other than its own.
- 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.
1function acme_preflight_api() {2 // get data from the database3 $data = get_option( 'acme_preflight_data', null );4 // send JSON response5 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );6 echo json_encode( $data );7 // die to prevent further output8 die();9}
Copied!
We have to do a few more things here.
- Send necessary Access-Control headers to tell the browser that it is okay to send request from domains other than the source.
- Check for preflight requests, basically HTTP
OPTIONS
request. - 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.
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 NOThttp://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.
1// preset option for allowed origins for our API server2$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 URL11if ( ! $request_origin ) {12 $request_origin = site_url( '' );13}1415// a fallback value for allowed_origin we will send to the response header16$allowed_origin = 'https://yoursite.com';1718// now determine if request is coming from allowed ones19if ( in_array( $request_origin, $allowed_origins ) ) {20 $allowed_origin = $request_origin;21}2223// print needed allowed origins24header( "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.
The request looks something like this:
1OPTIONS /acme-preflight/api/2Access-Control-Request-Method: GET3Access-Control-Request-Headers: origin, content-type4Origin: 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.
1HTTP/1.1 204 No Content2Connection: keep-alive3Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept4Access-Control-Allow-Origin: https://preflight.yoursite.com5Access-Control-Allow-Methods: POST, GET, OPTIONS6Access-Control-Max-Age: 86400
Copied!
Now we write the PHP code responsible for that.
1// print needed allowed origins2header( "Access-Control-Allow-Origin: {$allowed_origin}" );3header( 'Access-Control-Allow-Credentials: true' );4header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );56// if this is a preflight request7if (8 isset( $_SERVER['REQUEST_METHOD'] )9 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'10) {11 // need preflight here12 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );13 // just exit and CORS request will be okay14 // NOTE: We are exiting only when the OPTIONS preflight request is made15 // 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.
1// print needed allowed origins2header( "Access-Control-Allow-Origin: {$allowed_origin}" );3header( 'Access-Control-Allow-Credentials: true' );4header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );56// if this is a preflight request7if (8 isset( $_SERVER['REQUEST_METHOD'] )9 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'10) {11 // need preflight here12 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );13 // add cache control for preflight cache14 // @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 okay19 // NOTE: We are exiting only when the OPTIONS preflight request is made20 // 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:
1function acme_preflight_api() {2 // preset option for allowed origins for our API server3 $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 URL12 if ( ! $request_origin ) {13 $request_origin = site_url( '' );14 }15 // a fallback value for allowed_origin we will send to the response header16 $allowed_origin = 'https://yoursite.com';17 // now determine if request is coming from allowed ones18 if ( in_array( $request_origin, $allowed_origins ) ) {19 $allowed_origin = $request_origin;20 }2122 // print needed allowed origins23 header( "Access-Control-Allow-Origin: {$allowed_origin}" );24 header( 'Access-Control-Allow-Credentials: true' );25 header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );2627 // if this is a preflight request28 if (29 isset( $_SERVER['REQUEST_METHOD'] )30 && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'31 ) {32 // need preflight here33 header( 'Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept' );34 // add cache control for preflight cache35 // @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 okay40 // NOTE: We are exiting only when the OPTIONS preflight request is made41 // because the pre-flight only checks for response header and HTTP status code.42 exit( 0 );43 }4445 // get data from the database46 $data = get_option( 'acme_preflight_data', null );47 // send JSON response48 header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );49 echo json_encode( $data );50 // die to prevent further output51 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!