Michiel Kempen

Michiel Kempen

Hi, I'm Michiel, a passionate cloud engineer, full-stack webdeveloper,
and entrepreneur. I love to learn, teach, and build awesome things.
Currently making the cloud accessible at Smoothy.cloud ☁️🚀

Generate HTTPS URLs when running Laravel behind a proxy #

Earlier today I got bitten by a nasty bug in my Laravel code. Why nasty? Well, it was one of those bugs that lay low during development but as soon as you push to production they set your whole application on fire. 🔥

My immediate reaction was:

Ops problem now

However, after some debugging, the problem turned out to be a mix of both Dev and Ops.

Context #

Because I run my Laravel application behind a load balancer that terminates TLS, requests are forwarded from my load balancer to my application on port 80. This means that my application has no clue whether it is served over HTTP or HTTPS.

As a result, whenever I use the url() helper in my Laravel application — for example, to include an image in my Blade views — the generated URL uses the HTTP scheme. This works fine in development, but as soon as your application is served in production over HTTPS, the HTTP links are marked as mixed content by modern web browsers.

To solve this problem, I have had the following piece of code in my Laravel applications for years:

<?php

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        if(config('app.env') == 'production') {
            URL::forceScheme('https');
        }
    }
}

These three lines of code tell the url() helper to always use the HTTPS scheme whenever the application is running in production.

As a result, url('/img/icon.jpg') translates to:

  • http://example.com/img/icon.jpg in development
  • https://example.com/img/icon.jpg in production

Perfect solution, right?

At least, that is what I thought... until today.

The problem #

Apart from generating regular URLs via the url() helper, Laravel also allows you to generate signed URLs. These URLs have a signature hash appended to the query string that allows Laravel to verify that the URL has not been modified since it was created.

Signed URLs are especially useful for routes that are publicly accessible yet need a layer of protection. An example of such a URL is the public unsubscribe link that is included in newsletters.

You can create a signed URL to a named route as follows:

use Illuminate\Support\Facades\URL;

return URL::signedRoute('unsubscribe', ['user' => 1]);

In your controller you can then verify that an incoming request has a valid signature:

use Illuminate\Http\Request;

public function unsubscribe(Request $request)
{
    if (! $request->hasValidSignature()) {
        abort(401);
    }

    // ...
}

Under the hood, the signedRoute() method respects the URL::forceScheme('https') in the AppServiceProvider and generates a secure HTTPS URL in production.

https://example.com/unsubscribe/1

Laravel then creates a signature from this URL and appends it as a query parameter to the URL. The result looks something like this:

https://example.com/unsubscribe/1?signature=524e55e5154e2138278b9aa59172dddbb95a9e4f9c4d90ee0199e4a959a3fc64

The problem is that the URL::forceScheme('https') has no impact on the hasValidSignature() method. Therefore, the method sees the URL from the incoming request as:

http://example.com/unsubscribe/1

Since the schemes of the requested and the observed URL are different, their signatures are different as well. As a result, when Laravel compares the signatures to verify the validity of the request, it assumes that the requested URL has been tampered with and returns a 403 response.

And suddenly, my URL::forceScheme('https') solution doesn't look so perfect anymore...

The solution #

When a request passes through a proxy, a lot of the information about the client gets replaced by information about the proxy. For example, the REMOTE_ADDR header that normally contains the IP address of the client now contains the IP address of the proxy.

However, this information is not entirely lost. Many proxies forward the details of the original request in the form of X-Forwarded-* headers.

Common headers include:

  • X-Forwarded-For The IP address of the client
  • X-Forwarded-Host The hostname used to access the site in the browser
  • X-Forwarded-Proto The scheme used by the client
  • X-Forwarded-Port The port used by the client

This means that my assumption that Laravel has no clue about whether it is served over HTTP or HTTPS was wrong. It can actually figure this out, but it has to look at the X-Forwarded-Host header.

By default, Laravel does not take the X-Forwarded-* headers into account. But the TrustProxies middleware that is included by default in any Laravel application can change that.

So, to solve the problem, I configured the TrustProxies middleware to "trust" any proxy by setting $proxies = "*". This tells Laravel to look at the X-Forwarded-* headers whenever it receives a request that is forwarded by a proxy.

<?php

use Fideloper\Proxy\TrustProxies as Middleware;
use Illuminate\Http\Request;

class TrustProxies extends Middleware
{
    /**
     * The trusted proxies for this application.
     *
     * @var string|array
     */
    protected $proxies = "*";

    /**
     * The headers that should be used to detect proxies.
     *
     * @var string
     */
    protected $headers = Request::HEADER_X_FORWARDED_ALL;
}

Conclusion #

Thanks to the TrustProxies middleware, my Laravel application can now figure out by itself whether it should generate an HTTP or HTTPS URL, even when it is running behind a proxy. This works for both regular URLs and signed URLs.

And that piece of code that lived for years in the boot() method of my AppServiceProvider?

That I removed... since its functionality had become obsolete. 🗑

if(config('app.env') == 'production') {
    URL::forceScheme('https');
}
.dev domains not resolving due to Laravel Valet
Getting started with Homebrew on Mac