How to Improve Your Laravel Application's Security Using a CSP

Published on by

How to Improve Your Laravel Application's Security Using a CSP image

Content Security Policies (CSP) are a great way to improve the security of your Laravel application. They allow you to whitelist the sources of scripts, styles, and other assets that your webpages can load. This prevents an attacker from injecting malicious code into your view (and consequently, your users' browsers) and can give you added confidence that the third-party resources you're using are what you intended to use.

In this article, we're going to take a look at what a CSP is and what they achieve. We'll then take a look at how to use the spatie/laravel-csp package to add a CSP to your Laravel application. We'll also briefly cover some tips to make adding a CSP to an existing application easier.

What is a Content Security Policy?

In its simplest terms, a CSP is just a set of rules that are typically returned from your server to the client's browser via a Content-Security-Policy header in the response. It allows us, as developers, to define what assets the browser is allowed to load.

As a result of this, it can give us confidence that our users are only loading images, fonts, styles, and scripts into their browser that we've deemed safe for them to load and permitted the use of. If the browser tries to load an asset that isn't permitted, it will be blocked.

Using a well-configured content security policy can reduce the likelihood of user data theft and other malicious actions carried out using attacks such as Cross-Site Scripting (XSS).

CSPs can become very complex (especially in larger applications) but they are a vital part of any application's security.

How to Implement a CSP in Laravel

As we've already mentioned, a CSP is just a set of rules that are returned from your server to the client's browser via a header in the response, or sometimes defined as a <meta> tag in the HTML. This means that there's several ways that you can apply a CSP to your application. For example, you could define the headers in your server's (e.g. - Nginx) configuration. However, this can be cumbersome and difficult to manage, so I find that it's easier to manage the policy at the application level instead.

Typically, the easiest way of adding a policy to your Laravel application is to use the spatie/laravel-csp package. So let's take a look at how we can use it, and the different options that it provides us with.

Installation

To get started with using the spatie/laravel-csp package, we'll first need to install it via Composer using the following command:

composer require spatie/laravel-csp

Following this, we can then publish the package's config file using the following command:

php artisan vendor:publish --tag=csp-config

Running the above command should have created a new config/csp.php file for you.

Applying the Policy to Responses

Now that the package is installed, we need to make sure the Content-Security-Policy header is added to your HTTP responses. There are a few different ways you might want to do this, depending on your application.

If you'd like to apply the CSP to all your web routes, you could add the Spatie\Csp\AddCspHeaders middleware class to the web part of the $middlewareGroups array in your app/Http/Kernel.php file:

// ...
 
protected $middlewareGroups = [
'web' => [
// ...
\Spatie\Csp\AddCspHeaders::class,
],
 
// ...

As a result of doing this, any route that runs through your web middleware group, will have the CSP header automatically added for you.

If you'd prefer to add the CSP to individual routes or any route groups, you can use the middleware in your web.php file instead. For example, if we only wanted to apply the middleware to a specific route, we might do something like:

use Spatie\Csp\AddCspHeaders;
 
Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class);

Or, if we wanted to apply the middleware to a route group, we could do the following:

use Spatie\Csp\AddCspHeaders;
 
Route::middleware(AddCspHeaders::class)->group(function () {
// Routes go here...
});

By default, if you don't explicitly define the policy that should be used with the middleware, the policy defined in the default key of your published config/csp.php file will be used. So you may want to update that field if you want to use your own default policy.

It's possible that you may have several content security policies for your application or website. For instance, you might have a CSP for use on the public pages of your site, and another CSP for use on the gated parts of your site. This could be due to you using different sets of assets (such as scripts, styles, and fonts) in each of these places.

So if we wanted to explicitly define the policy that should be used for a specific route, we could do the following:

use App\Support\Csp\Policies\CustomPolicy;
use Spatie\Csp\AddCspHeaders;
 
Route::get('example-route', 'ExampleController')->middleware(AddCspHeaders::class.':'.CustomPolicy::class);

Similarly, we could also explicitly define the policy in a route group:

use App\Support\Csp\Policies\CustomPolicy;
use Spatie\Csp\AddCspHeaders;
 
Route::middleware(AddCspHeaders::class.':'.CustomPolicy::class)->group(function () {
// Routes go here...
});

Using the Default Content Security Policy

The package ships with a default Spatie\Csp\Policies\Basic policy that defines a few rules already for us. The policy only allows us to load images, fonts, styles, and scripts from the same domain as our application. If you're only using assets that are loaded from your own domain, this policy may be enough for you.

The Basic policy would create a Content-Security-Policy header that looks something like this:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self';media-src 'self';object-src 'none';script-src 'self' 'nonce-YKXiTcrg6o4DuumXQDxYRv9gHPlZng6z';style-src 'self' 'nonce-YKXiTcrg6o4DuumXQDxYRv9gHPlZng6z'

Creating Your Own Content Security Policy

Depending on your application, you may want to create your own policy to allow loading other assets that are permitted by the Basic policy.

As we've already mentioned, there are a lot of rules that can be defined in a CSP, and they can become relatively complex quickly. So to help you get a brief understanding, we'll take a look at a few of the common rules that you'll likely be using in your own application.

For the purpose of this guide, we're going to make the assumption that we have a project that uses the following assets on the page:

  • A JavaScript file available on the site's domain at: /js/app.js.
  • A JavaScript file available externally at: https://unpkg.com/vue@3/dist/vue.global.js.
  • Inline JavaScript - But not just any inline JavaScript, we only want to allow inline JavaScript that we've explicitly permitted to run.
  • A CSS file available on the site's domain at: /css/app.css.
  • A CSS file available externally at: https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css
  • An image available on the site's domain at: /img/hero.png.
  • An image available externally at: https://laravel.com/img/logotype.min.svg.

We'll create a content security policy that only allows the loading of the above items on our page. If the browser attempts to load any other asset, the request will be blocked and it won't be loaded.

The basic Blade view for the page may look something like this:

<html>
<head>
<title>CSP Test</title>
 
{{-- Load Vue.js from the CDN --}}
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
 
{{-- Load some JS scripts from our domain --}}
<script src="{{ asset('js/app.js') }}"></script>
 
{{-- Load Bootstrap 5 CSS --}}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
crossorigin="anonymous"
>
 
{{-- Load a CSS file from our own domain --}}
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>
 
<body>
<h1>Csp Header</h1>
 
<img src="{{ asset('img/hero.png') }}" alt="CSP hero image">
 
<img src="https://laravel.com/img/logotype.min.svg" alt="Laravel logo">
 
{{-- Define some JS directly in our HTML. --}}
<script>
console.log('Loaded inline script!');
</script>
 
{{-- Evil JS script which we didn't write ourselves and was injected by another script! --}}
<script>
console.log('Injected malicious script! ☠️');
</script>
</body>
</html>

To get started, we'll first want to create our own policy class that extends the package's Spatie\Csp\Policies\Basic class. There isn't a particular directory that you need to place this in, so you can choose somewhere that works best for your application. I like to place mine in an app/Support/Csp/Policies directory, but that's just my preference. So I'll create a new app/Support/Csp/Policies/CustomPolicy.php file:

namespace App\Support\Csp\Policies;
 
use Spatie\Csp\Policies\Basic;
 
class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();
 
// We can add our own policy directives here...
}
}

As you can see from the comment in the code above, we can place our own custom directives in the configure method.

So let's add some directives and take a look at what they do:

namespace App\Support\Csp\Policies;
 
use Spatie\Csp\Policies\Basic;
 
class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();
 
$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/'])
->addDirective(Directive::IMG, 'https://laravel.com');
}
}

The above policy would create a Content-Security-Policy header that looks something like this:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self';media-src 'self';object-src 'none';script-src 'self' 'nonce-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://unpkg.com/vue@3/;style-src 'self' 'nonce-3fvDDho6nNJ3xXPcK3VMsgBWjVTJzijk' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/

In our example above, we've defined that any JS file that is loaded from a URL that begins with https://unpkg.com/vue@3/ can be loaded. This means that our Vue.js script would be able to load as expected.

We've also permitted any CSS file that is loaded from a URL that begins with https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/ to be loaded.

Additionally, we've also permitted any image that is fetched from a URL that begins with https://laravel.com to be loaded.

You might also be wondering where the directives are for allowing inline JavaScript to be run, and images, CSS, and JS files to be loaded from our domain. These are all included in the Basic policy, so we don't need to add them ourselves. So we can keep our CustomPolicy nice and lean, and only add the directives that we need (typically for external assets).

However, at the moment, if we were to try running our inline JavaScript, it wouldn't work. We'll cover how to fix this further down.

Although the rules above work and will allow our page to load as expected, you might want to make the rules stricter to further increase the security of the page.

Let's imagine that, for any unknown reason, a malicious script managed to make its way to a URL that starts with https://unpkg.com/vue@3/, such as https://unpkg.com/vue@3/malicious-script.js. Due to the current configuration of our rules, this script would be allowed to be run on our page. So instead, we might want to explicitly define the exact URL of the script that we want to allow to be loaded.

We'll update our policy to include the exact URLs of the scripts, styles, and images that we want to load:

namespace App\Support\Csp\Policies;
 
use Spatie\Csp\Policies\Basic;
 
class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();
 
$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/dist/vue.global.js'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css'])
->addDirective(Directive::IMG, 'https://laravel.com/img/logotype.min.svg');
}
}

The above policy would create a Content-Security-Policy header that looks something like this:

base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-20gXfzoeWpjyg1ryUkWAma5gMWNN03xH' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-20gXfzoeWpjyg1ryUkWAma5gMWNN03xH' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css

By using this approach above, we can vastly improve the security of our page, as we're now only allowing the exact scripts, styles, and images that we want to be loaded.

However, as you might imagine, doing this for larger projects can become tedious and time-consuming because you'll need to define every single asset you're loading from an external source. So this is something that you'll need to consider on a project-by-project basis.

Adding Nonces to Your CSP

Now that we've looked at how we can allow external assets to be loaded, we also need to take a look at how we can allow inline scripts to be run.

You might recall that we had two inline script blocks in our Blade view above:

  • One that loaded the JS that we intended to run
  • One that was injected by a malicious script and ran some evil code!

The script was added to the bottom of the Blade view looked like so:

<html>
<!-- ... -->
 
{{-- Define some JS directly in our HTML. --}}
<script>
console.log('Loaded inline script!');
</script>
 
{{-- Evil JS script which we didn't write ourselves and was injected by another script! --}}
<script>
console.log('Injected malicious script! ☠️');
</script>
</body>
</html>

To allow inline scripts to be run, we can make use of "nonces". A nonce is a random string that is generated for each request. This string is then added to the CSP header (added via the Basic policy that we are extending), and any inline script that is loaded must include this nonce in its nonce attribute.

Let's update our Blade view to include the nonce for our safe inline script by using the csp_nonce() helper provided by the package:

<html>
<!-- ... -->
 
{{-- Define some JS directly in our HTML. --}}
<script nonce="{{ csp_nonce() }}">
console.log('Loaded inline script!');
</script>
 
{{-- Evil JS script which we didn't write ourselves and was injected by another script! --}}
<script>
console.log('Injected malicious script! ☠️');
</script>
</body>
</html>

As a result of doing this, our safe inline script will now be run as expected. Whereas the injected script that doesn't have the nonce attribute will be blocked from running.

Using a Meta Tag

It's unlikely, but it's possible you may find that the contents of your Content-Security-Policy header exceeds the maximum allowed length. If this is the case, we can add a meta tag to our page that outputs the rules for our browser instead.

To do this, you can add the package's @cspMetaTag Blade directive to your view's <head> tag like so:

<html>
<head>
<!-- ... -->
 
@cspMetaTag(App\Support\Csp\Policies\CustomPolicy::class)
</head>
 
<!-- ... -->
 
</html>

Using the example of our CustomPolicy above, this would output the following meta tag:

<meta http-equiv="Content-Security-Policy" content="base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-oLbaz3rNhqvzKooMU8KpnqxgO9bFG1XQ' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-oLbaz3rNhqvzKooMU8KpnqxgO9bFG1XQ' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css">

Tips for Implementing a CSP in an Existing Laravel Application

Adding a CSP to an existing application can be a pretty difficult task sometimes. It's very easy to break your user interface by implementing a CSP that is too strict, or forgetting to add a rule for a particular asset that might only be used on one page. I'll hold my hand up and admit that I've done this myself before.

So if you get the chance to implement a CSP when first starting a new application, I'd highly recommend doing it. It's much easier to write the policy alongside building the application. There's a smaller chance of you forgetting to add specific rules and you can even add the policy rules in the same git commit as you've added the asset so you can easily track it in the future.

However, if you're adding the CSP to an existing application, there are a few things you can do to make the process easier for yourself and your users.

First off, you can enable "report only" mode for your policy. This allows you to define your policy, but whenever any of the rules are violated (such as loading an asset that's not been permitted to be loaded), a report will be sent to a given URL rather than blocking the asset from loading. By doing this, it allows you to create the CSP that you'd like to use and test it in your production environment without breaking your application for users. You can then use the reports to identify any assets that you've missed and add them to your policy.

To enable reporting for your policy, you'll first need to set the URL that the request should be made to when a violation is detected. You can add this by setting the CSP_REPORT_URI field in your .env file like so:

CSP_REPORT_URI=https://example.com/report-sent-here

You can then use the reportOnly method in your policy. If we were to update our policy to only report violations, it would look like so:

namespace App\Support\Csp\Policies;
 
use Spatie\Csp\Policies\Basic;
 
class CustomPolicy extends Basic
{
public function configure()
{
parent::configure();
 
$this->addDirective(Directive::SCRIPT, ['https://unpkg.com/vue@3/dist/vue.global.js'])
->addDirective(Directive::STYLE, ['https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css'])
->addDirective(Directive::IMG, 'https://laravel.com/img/logotype.min.svg')
->reportOnly();
}
}

As a result of using the reportOnly method, a Content-Security-Policy-Report-Only header will be added to the response instead of the Content-Security-Policy header. The policy above would generate a header that looks like so:

report-uri https://example.com/report-sent-here;base-uri 'self';connect-src 'self';default-src 'self';form-action 'self';img-src 'self' https://laravel.com/img/logotype.min.svg;media-src 'self';object-src 'none';script-src 'self' 'nonce-hI66wwieLS9inQh9GO4iaItVTFoPcNnj' https://unpkg.com/vue@3/dist/vue.global.js;style-src 'self' 'nonce-hI66wwieLS9inQh9GO4iaItVTFoPcNnj' https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css

After a certain period of time (maybe a few days, week, or months), if you've not received any reports, and you have confidence that the policy is suitable, you could enable it. This means that you'll still be able to get the reports when there's a violation, but you'll also be able to get the full security benefits of implementing the policy, as any violations will be blocked. To do this, you can remove the reportOnly method call from your policy class.

Additionally, you may also find it useful to incrementally increase the strictness of your rules like we covered earlier in this article. So may want to only use domains or wildcards in your initial CSP and then gradually change the rules to use more specific URLs.

All in all, I think the key to adopting using CSPs in your existing application is to approach it gradually. It's definitely possible to add it all in one go, but you can reduce the chance of errors and bugs by taking a more incremental approach.

Conclusion

Hopefully, this article should have given you an overview of CSPs, the issues they solve, and how they work. You should also now know how to implement a CSP in your own Laravel application using the spatie/laravel-csp package.

You may also want to check out the MDN documentation on CSPs, which is explains more of the options available for you to use in your applications.

Ashley Allen photo

I am a freelance Laravel web developer who loves contributing to open-source projects, building exciting systems, and helping others learn about web development.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article