Be able to solve common CORS errors, hassle-free

CORS (Cross-Origin Resource Sharing) is a feature built into browsers for extra security. By default, you won't be allowed to make request from a browser, to a domain that differs from the origination domain.

So, if your domain is mysite.com, you can't fetch data from othersite.com, when using the browser. Domain othersite.com will have to explicitly allow you, to do so.

That's the general idea, but that's not enough to know (as an engineer). On this article, we are going to deeply understand why this is useful on the first hand. After that let's focus on different annoying CORS errors, what they mean and how to fix them.

The goal is to understand what's going on, and to be able to handle any CORS error. At the end of the day CORS is there to protect us, so give it some love.

Important note: Code used below is made to help us understand the concepts, not to be used on your app. This article is to understand CORS. It is not made with production code in mind.

Why do we need CORS?

Imagine you log into your bank, and after the first authentication, your browser receives a session cookie. With that cookie, your bank understand who you are and authenticates your next requests.

Knowing this, I create an "evil" website, where, when you visit, it makes "invisible" API calls to your bank API, since cookies will be sent automatically by the browser.

Here CORS come. I just wasted my time, because my evil website's URL is not on the list of allowed domains. This is a cross-origin request and is not allowed by default.

Hopefully you got the point of we this is great, with this example.

CORS common errors, one by one, by example

Even though CORS is there to secure browsers, there are some times that it bother us, even if we do not try to do something with bad intentions.

Let's start this small journey with CORS errors, with a very regular use case, that we can meet, as developers.

  • We have a Laravel API, on https://backend.com
  • We have our main frontend on https://frontend.com
  • Lastly, we have one subdomain for our frontend on https://dashboard.frontend.com

That means that our backend lives on different domain (could be just different subdomain too) from our frontend. I have kept the names pretty simple so they do not confuse us.

Now we definitely want out frontend to make API calls on our backend.

We are going to pretend that this is a blogging system.

On our homepage, we are doing a very simple GET request to fetch our header text, that lives on our backend, so it is editable on the database. The endpoint just returns some text back.

fetch("https://backend.com/api/header")

and BOOM, we get our first CORS error.

  1. No 'Access-Control-Allow-Origin' header is present on the requested resource

And that's there to make our day worse. Not if we google a little, but wouldn't it be great to know exactly what we are doing? Let's dive into this first error.

We tried to fetch our header from the backend, on the browser but from a different domain, using a simple GET request, and this is not allowed. Even if the errors appear on our browser, we need to fix that on the server.

To really understand this, we are going to fix this without using any third package library, but using our own code, from scratch. Also latest versions of Laravel, have CORS on their core, but to understand it better, we are going to pretend that they don't.

So, what we need to do for this error, is to return a header "Access-Control-Allow-Origin" that contains our allowed origins. So let's create a middleware that will handle our CORS.

php artisan make:middleware Cors

And register it on our global middlewares, for simplicty. (If you follow the example, don't forget to register the middleware).

<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Cors
{

    public function handle(Request $request, Closure $next): Response
    {
        return $next($request)
            ->withHeaders([
                'Access-Control-Allow-Origin' => 'https://frontend.com'
            ]);
    }
}

Make the call again on the frontend

fetch("https://backend.com/api/header")

And voilla! We fixed our first error, we receive the response successfully.

Now, we noticed that dashboard.frontend.com also needs the header, so we do the same there, using the exact same fetch. But guess what, we see another CORS error!

  1. The 'Access-Control-Allow-Origin' header has a value 'https://frontend.com' that is not equal to the supplied origin. Have the server send the header with a valid value, or, if an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Welcome to the 2nd error. What the hell that means? It's pretty simple actually. This happens when we return "Access-Control-Allow-Origin" from our server, but the actual origin is not there. Let's fix that, updating our middleware:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Cors
{

    public function handle(Request $request, Closure $next): Response
    {
        $allowedOrigins = [
            'https://frontend.com',
            'https://dashboard.frontend.com'
        ];

        if(in_array($request->header('origin'), $allowedOrigins)) {
            return $next($request)
                ->withHeaders([
                    'Access-Control-Allow-Origin' => $request->header('origin')
                ]);
        }

        return $next();
    }
}

Try again, and the request is successful and we got our header on our subdomain too! By the way, browser automatically sends "origin" header for us, so do not worry about it.

Let's continue.

We also have an endpoint on our backend that returns a JSON response of our posts. Now we have to make a more complex request on our frontend, and what I mean by complex, is that we also need to specify some headers. So we fetch our posts like that:

fetch("https://backend.com/api/posts", {method: "GET", headers: {"Content-Type": "application/json"}})

And.....here comes the third error.

  1. Access to fetch at 'http://backend.com/api/posts' from origin 'https://frontend.com' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

Note: We are going to talk about preflight response, on the next error, just remember the phrase. Also note that your browser did 2 requests to the server. You can see this going to your Network tab, All selected. There is one extra OPTIONS request there, to the same URI. Will be covered on the error 4.

When we have some more complex requests (for example having headers), we get this error. And that's why because we need to let our browser know, not only the origins we allow, but also the headers. This one, using "Access-Control-Allow-Headers".

For one more time let's update our middleware:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Cors
{

    public function handle(Request $request, Closure $next): Response
    {
        $allowedOrigins = [
            'https://backend.com',
            'https://dashboard.backend.com'
        ];
        
        $allowedHeaders = [
            'Content-Type'
        ];

        if(in_array($request->header('origin'), $allowedOrigins)) {
            return $next($request)
                ->withHeaders([
                    'Access-Control-Allow-Origin' => $request->header('origin'),
                    'Access-Control-Allow-Headers' => $allowedHeaders

                ]);
        }

        return $next();
    }
}

We try again and we get our posts successfully! Through this example, we have covered 3 errors.

We now have a button on our posts list, that deletes a post, using a delete method.

Like that:

fetch("https://backend.com/api/posts/1", {method: "DELETE", headers: {"Content-Type": "application/json"}})

And as you maybe already guessed, we get another error:

  1. Access to fetch at 'https://backend.com/api/posts/1' from origin 'https://frontend.com' has been blocked by CORS policy: Method DELETE is not allowed by Access-Control-Allow-Methods in preflight response.

Similar error with the previous one, we see again the preflight response.

And if you open your Network tab selecting All, you will notice something strange. Your browser tried to do 2 requests.

  • Your DELETE request did not even started
  • An OPTIONS request was made to your backend

Let's analyse that,

First of, this error is clearly because that we didn't specify the allowed methods, using the "Access-Control-Allow-Methods" header.

But what the heck is the OPTIONS request that your browser did? Do you remember the "preflight response" phrase from 3rd error?

For PUT, PATCH, DELETE requests, or requests with non standard headers, your browser sends a pre-flight OPTIONS request to your server, to see if the server allows the request you asked for, to be sent from this origin. (It asks if provided headers/method is allowed)

If server allows that, then the initial request is being sent.

If not, your browser is not even sending the request you asked for.

Pre-flight requests are being sent to the same URI with the initial request.

In our example that means it will send an OPTIONS request to

"https://backend.com/api/posts/1"

Updating our middleware would do the work for us.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Cors
{

    public function handle(Request $request, Closure $next): Response
    {
        $allowedOrigins = [
            'https://frontend.com',
            'https://dashboard.frontend.com'
        ];

        $allowedHeaders = [
            'Content-Type'
        ];

        $allowedMethods = [
            'GET',
            'POST',
            'DELETE'
        ];

        if(in_array($request->header('origin'), $allowedOrigins)) {
            return $next($request)
                ->withHeaders([
                    'Access-Control-Allow-Origin' => $request->header('origin'),
                    'Access-Control-Allow-Headers' => $allowedHeaders,
                    'Access-Control-Allow-Methods' => $allowedMethods
                ]);
        }

        return $next();
    }
}

Note that we could even create a specific route that handles our OPTIONS request. Something like:

 Route::options('posts/{id}', function(){
    return response()->noContent()
        ->withHeaders([
            'Access-Control-Allow-Methods' => 'DELETE'
        ]);
});

But middleware just makes our life easier, since it's global. Also that's for our example right? You may not use your middleware globally, but on routes you want to expose for different origins. Most of the times one global middleware is enough, but it highly depends! Please understand what you are doing.

Hopefully you got the above 4 errors. You can now see CORS errors and fix them when they appear.

Don't use them randomly

My personal suggestion would be to not randomly use CORS headers. Know what you are doing because you may add serious vulnerabilities to your application without even noticing. Laravel makes that harder, but values are still editable.

For example you probably wouldn't want to do something like

'Access-Control-Allow-Origin' => '*'

which allows every single external origin to access your website from the browser, but always depends on your case! Act accordingly.

Browser error, but the solution is on the server

You are going to see CORS error on the browser. But most of the times, if not all, you have to access the server, responding back with the correct Headers, to resolve the issue. Keep that in mind. If you understand what's going on, then you can fix it.

Aren't pre-flight requests spammy? They are still extra requests that I didn't ask for, on my server,

You can specify "Access-Control-Max-Age" header, which is a number in seconds. This is the number of seconds that your browser will cache the pre-flight response. By default this is 5.

Note that you can't set whatever you want, there is some limits, different browsers have different rules.

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). These may change, just keep that in mind.

Subscribe to Lioy

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe