Content Security Policy (CSP)
Using Content Security Policy (CSP) headers is not required to make an app work, but it is highly recommended as it will make your project more secure
I like to set up the CSP headers as early as possible because if you wait until the last moment before going into production and then decide to add them, then you will probably have a bunch of violations that get reported, and it might take some time to adjust your CSP rules, this why I recommend starting as early as possible and fix the violations one by one as soon as they occur
Adding CSP Headers in Next.js configuration
In this chapter, we are to add CSP rules to our next.config.mjs
configuration file (which is in the root of the project) like so:
Lines 17 to 24: we add a headers
configuration where we use the source
property to tell Next.js that it should add those headers to every page, then we set a second headers
property, and as the value, we make a call to our securityHeadersConfig
function
Line 33: we have added a cspReportOnly
variable and have set it to true; we will use this variable to decide if we want to only report CSP violations or enforce CSP rules and report them; we start with true so that violations get reported but not enforced and later when we are sure that we have set out rules correctly and fixed potential violations then we will set this to false to start not only reporting but also enforcing CSP rules
Lines 35 to 97: we have added a relatively long cspHeader()
function, which will create 4 sets of CSP rules:
- The first set of CSP rules are the default rules that we will enable no matter the environment
- The second set of rules is for when the environment is preview, which is the case when you deploy the preview branch on Vercel; this is why this part contains a lot of URLs related to Vercel; those are sources for scripts that Vercel uses, for example, to add a comment system to your previews
- The next set contains the rules for the production environment; this part is essential as you need to ensure that you are NOT blocking any legitimate sources here, or it will create bugs in production that will impact your users
- The last set has the rules we use for our local development environment; for example, if you look at the
script-src
directive, you will see that we added'unsafe-inline' 'unsafe-eval'
, now compare it with thescript-src
for the production rules, and you will see that those two values, this is because we need to be more permissive in development as Next.js uses tools like the Hot Reload package to do fast refreshes, which is a tool that is not being used in production, so in production we are more restrictive
I recommend you always start with the most restrictive rules possible. For example, if you look at the top of default CSP rules, I have set the form-action to none. This is because, in this tutorial, we will not have any forms, so there is no reason to allow them; however, if you add forms to your project in the future, then you will, of course, want to adjust the directive and for example, set it to 'self' instead of 'none'
Line 37: we have added a upgradeInsecure
variable that we will only contain the CSP: upgrade-insecure-requests directive if we are not in development mode and only if cspReportOnly is false, this is because most often dev servers use HTTP requests as they have no SSL certificate installed, but for preview and production mode we add the directive, the directive tells the browser that it should assume that every request is a secure HTTPS request, however if cspReportOnly is false, then this directive does not apply, if you still enable it while in "report only" mode than you will get these errors in the console:
The Content Security Policy directive 'upgrade-insecure-requests' is ignored when delivered in a report-only policy.
Lines 99 to 104: we create a header for our CSP rules, for the header key we use the cspReportOnly variable we added at the top, depending on the value of cspReportOnly we either set the CSP header to Content-Security-Policy-Report-Only (if cspReportOnly = true) or we set it to Content-Security-Policy (if cspReportOnly = false), this means that if cspReportOnly is true we will only report violations but not enforce them, so if for example you try to load a script from a source that is forbidden it will still get loaded but the browser will alert you about the violation, this mode is helpful for as long as we are unsure about our CSP setup and want to watch for potential violations but do NOT enforce them yet, when we are sure that our CSP rules have been fine tuned and will not block legit sources then we set cspReportOnly to false, meaning from now on we do NOT just report but actually also enforce the rules, finally as the header value we make a call to our cspHeader()
function which returns a string, we also use replace
to remove all line breaks
When enforcing is enabled, it will still report the violations (besides enforcing them)
For now, we set the CSP mode to only report violations.
However, as soon as we are confident that there are no more violations, it is recommended to set our custom variable cspReportOnly to false, especially when you are done testing and decide to put everything into production.
The CSP headers I added in the tutorial are based on Next.js recommendations that can be found in the Next.js "Configuring CSP" documentation
If you now start your development server (using npm run dev
), open http://localhost:3000
in your browser, open the browser developer tools, and then click on the Console Tab, then you should see no CSP violations messages
Example of a CSS violation
To check for best practices, I used a tool by Google called CSP Evaluator, it showed a green checkmark for every directive except the script-src directive, where it mentioned that it would be better to remove 'unsafe-inline', however unsafe-inline is there because Next.js uses inline scripts a lot
Let's edit the CSP rules we just added in our next.config.mjs
file and make the script-src directive stricter by not using unsafe-eval as recommended by the CSP Evaluator service, like so:
Line 90 we remove 'unsafe-eval'
for the script-src directive
Go back into the browser and check the Console Tab again. You should now be able to see a bunch of errors like these:
[Report Only] Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' 'unsafe-inline'".
Those violations are there because Next.js uses the JavaScript eval() function, eval is required for development scripts like the Hot Module Reload (HMR) tool, which is a tool that reloads our page every time we save a file.
JavaScript eval() function can be dangerous when you display content that got through user inputs (like a textarea or an input field), as a malicious actor could inject Javascript as a sting and then your eval function would turn that string into executable Javascript that could do things like stealing user credentials to a server owned by the malicious actor
For HMR to work, we need to re-add the 'unsafe-eval value to the script-src rule. Do that now, and then save the file to fix violations again.
we only add 'unsafe-eval' when in development mode, we do not add 'unsafe-eval' to preview or production, HMR is only active in development so there is no need to add 'unsafe-eval' in preview or production
Logging CSP violations
You should log CSP violations the same way you log errors in your code to ensure they don't go unnoticed and to be able to fix them promptly. If the CSP rules get enforced, they might trigger violations. If unhandled, those violations will probably create bugs on your website, which is why you want to keep an eye on those violations by logging them.
Several logging service providers offer to log CSP violations. I will use Sentry.io in this tutorial because it is already the tool we use for error logging. However, feel free to choose another provider if you find one you prefer or even create your CSP violations logging tool if you have the capacity to develop, host, and maintain such a project.
Why Sentry.io (is not yet) the ideal solution (and why we will still use it)
In a lot of places (when reading about Sentry.io CSP violation logging), including in their documentation (as of now 01.04.2024), you will read that it is recommended to use both the report-to as well as report-uri as a fallback.
The above works for Firefox, which does not yet support report-to but does support report-uri, so Firefox will fallback and use report-uri
However this does NOT work when using chrome (or any chromium based browser like edge and brave), chrome (>96) will attempt to use the report-to directive (defined as fallback in the Sentry.io documentation example), chrome will then also assume you are using the Reporting-Endpoints header from the Reporting API v1, however the Sentry.io example uses the Report-To header from the Reporting API v0 which chrome (>96) does NOT support (anymore).
Meaning chrome will queue the reports and then attempt to send them, but as it will not find a valid endpoint definition the requests will fail (chrome will put their status back to "Queued" for another attempt and after a while will set the status to "MarkedForRemoval").
After failing to send the reports chrome will never fall back to using the report-uri directive. You might be tempted to replace the Report-To header from the Sentry.io example with the new Reporting-Endpoints header however Sentry.io does NOT support the Reporting-Endpoints header yet, so that's also not an option.
In the next chapter, we will use Sentry.io that we have set up earlier for error logging purposes and add CSP violations logging
For a more in-depth look at the evolution of CSP and violation logging, I recommend checking out my CSP post
We will only use the report-uri directive from the CSP v1 specification, as this solution works in Chrome, Firefox, and Safari.
Keep an eye on CSP violation logging techniques as browsers and logging services will, one after the other, start supporting the Reporting API v1, and when they all do, I recommend replacing the report-uri directive with the report-to directive and also start using the Reporting-Endpoints header as soon as Sentry supports it
The major drawback when using the report-uri directive is that it makes a request to your logging service for each violation it finds (the new reporting API v1 queues violations and then sends them all in one batch to the logging service), which is why I recommend only to enable logging periodically, to ensure that you are not using up your entire quota in just a few hours/days, if you look at big web platforms you will notice that, even though they have CSP rules, they also often remove the reporting when not needed. They only turn it on when there is a bug, so that they can use the reporting for debugging, because they suspect the CSP rules to be the cause.
Setting up CSP violations logging using Sentry.io
First, you need to visit Sentry.io and copy the CSP reporting URL of your project:
- visit Sentry.io and log in
- in the left navigation on the bottom, click on Settings
- Then, in the Settings navigation on the left, click on Projects
- Click on the project name
- Then in navigation on the left, under SDK SETUP, click on Security Headers
- On the Security Header Reports page, copy the URL under REPORT URI
- finally replace the URL for the const reportingUrl = 'INSET_YOUR_SENTRY_REPORT_URI_HERE' in the following code by the CSP REPORT URI from your Sentry account
- For the reportingDomainWildcard variable, I used
https://*.ingest.us.sentry.io
as this is the domain name used in the Sentry REPORT URI, if your Sentry account is in the EU your REPORT URI might havehttps://*.ingest.eu.sentry.io
domain, in which case you also need to change the reportingDomainWildcard variable value to behttps://*.ingest.eu.sentry.io
Next, we need to ensure violations get sent to Sentry.io (logged like any other error), to do that we will edit our CSP setup in the next.config.mjs
file, like so:
Lines 35 to 36: we add two new variables to store the Sentry.io CSP logging URL and a wildcard for the Sentry.io ingest sub-domain
- The first variable contains the CSP logging URL INSET_YOUR_SENTRY_REPORT_URI_HERE, we use that variable to tell the report-uri directive where to send CSP violations reports (to what endpoint), make sure you replace the INSET_YOUR_SENTRY_REPORT_URI_HERE placeholder with your own DSN from Sentry.io as described at the top of this chapter
- The second variable contains a wildcard for the reporting domain, so that we can add the domain to our connect-src directive
As most devs that use Sentry are using the service that is located in the US, the reporting domain wildcard should be ok as it is, but if your Sentry account is in the EU, then you need to change it to this value to https://*.ingest.eu.sentry.io
The difference is that we replace us with eu before the last part which is sentry.io
If using a wildcard does not work for you, you could also just put the full URL into the reportingDomainWildcard variable, that works too, the wildcard is just more flexible in case you generate a new DSN it will still be valid
Lines 42 to 43: we use a template literal to create the reporting uri directive; this directive will tell the browser what URL it should use when sending the CSP reports
Line 72 and 87: we add the reportingDomainWildcard to the connect-src directive
Line 75 and 90: we add the reportCSPViolations variable, which contains a reporting directive, meaning we only report violations to sentry for preview and production deployments but NOT local development
It is essential to add the reportingDomainWildcard to the connect-src directive, or CSP will block the reporting URL and not send reports to Sentry. We only add the reportingDomainWildcard to the connect-src for preview and production, but NOT development, as Sentry.io will filter out reports from localhost anyway.
If you want to debug your code, you might want to also add the reporting for development. In that case, add the ${reportCSPViolations}
and ${reportingDomainWildcard}
variables to the development directives too (same as for preview and production) and then check out the chapter about disableng the "reports from localhost" filter in my Sentry.io post as you will need to disable the filter for localhost reporting in your Sentry configuration on Sentry.io
If you want to only allow certain domains to be able to send in reports, then you can whitelist those domains, meaning Sentry.io will filter out reports that come from other domains (and use your Sentry DSN), I have a chapter in my Sentry.io post that goes explains how to set up an allowed domains list using the Sentry UI
Adding security headers
There are also some useful security headers besides CSP headers. Let's add 3 of those security headers to our Next.js configuration.
Next.js configuration security headers
For now we only added the CSP setup to every page header by altering the Next.js configuration file, next we are going to add 4 more security headers:
Lines 107 to 117: we edit the content of our securityHeadersConfig function and add a new extraSecurityHeaders
variable to store the HSTS header, but as we want to exclude it in development where we don't have an SSL certificate, we check if the phase is NOT development
The HSTS header (Strict-Transport-Security
) tells the browser that this app only supports HTTPS. We want the browser to always use HTTPS for every request the code of the page will make, even if the URL scheme of the content it needs to fetch is HTTP.
Line 120: we use the extraSecurityHeaders
variable to add the Strict-Transport-Security
header to the list of headers
Lines 125 to 136: we add 3 more security headers to the list of headers:
- the first one is a
Referrer-Policy
header, which tells the browser when and when NOT to include information about the origin in referrer header, the MDN "Referrer-Policy" documentation does a very good job at explaining the different values, I like to only set the referrer for internal pages but not for external pages, that's why I set the value same-origin - The second X-Content-Type-Options header tells the browser NOT to attempt to guess the MIME type of resource by itself (which is a technique called MIME type sniffing) and instead take the value that we (our CDN) puts into the header, the problem with MIME type sniffing is that a malicious actor could hide code in the MIME type string of a file to then perform an attack on the tool doing the MIME type sniffing
- The third one is the
X-Frame-Options
header, when set to deny it does the same thing as the frame-ancestors directive (we added earlier) when it is set to none, but it is for older browsers that did not have support for the directive, If you want to decide for yourself, then have a look at the caniuse "frame-ancestors" page and if you think support for the directive is high enough then you can drop X-Frame-Options
Congratulations 🎉 you just made your project a lot more secure by setting up CSP headers and reporting potential violations
If you liked this post, please consider making a donation ❤️ as it will help me create more content and keep it free for everyone