Chris.luChris.lu header image, displaying an 80s style landscape and sunset

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

Tip

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:

next.config.mjs
import { withSentryConfig } from '@sentry/nextjs';
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js'
 
const nextConfig = (phase) => {
 
    /** @type {import('next').NextConfig} */
    const nextConfigOptions = {
        reactStrictMode: true,
        poweredByHeader: false,
        experimental: {
            // experimental typescript "statically typed links"
            // https://nextjs.org/docs/app/api-reference/next-config-js/typedRoutes
            // currently false in prod until PR #67824 lands in a stable release
            // https://github.com/vercel/next.js/pull/67824
            typedRoutes: phase === PHASE_DEVELOPMENT_SERVER ? true : false,
        },
        headers: async () => {
            return [
                {
                    source: '/(.*)',
                    headers: securityHeadersConfig(phase)
                },
            ];
        },
    }
 
    return nextConfigOptions
 
}
 
const securityHeadersConfig = (phase) => {
 
    const cspReportOnly = true
 
    const cspHeader = () => {
 
        const upgradeInsecure = (phase !== PHASE_DEVELOPMENT_SERVER && !cspReportOnly) ? 'upgrade-insecure-requests;' : ''
 
        // worker-src is for sentry replay
        // child-src is because safari <= 15.4 does not support worker-src
        const defaultCSPDirectives = `
            default-src 'none';
            media-src 'self';
            object-src 'none';
            worker-src 'self' blob:;
            child-src 'self' blob:;
            manifest-src 'self';
            base-uri 'none';
            form-action 'none';
            require-trusted-types-for 'script';
            frame-ancestors 'none';
            ${upgradeInsecure}
        `
 
        // when environment is preview enable unsafe-inline scripts for vercel preview feedback/comments feature
        // and whitelist vercel's domains based on:
        // https://vercel.com/docs/workflow-collaboration/comments/specialized-usage#using-a-content-security-policy
        // and white-list vitals.vercel-insights
        // based on: https://vercel.com/docs/speed-insights#content-security-policy
        if (process.env.VERCEL_ENV === 'preview') {
            return `
                ${defaultCSPDirectives}
                font-src 'self' https://vercel.live/ https://assets.vercel.com https://fonts.gstatic.com;
                style-src 'self' 'unsafe-inline' https://vercel.live/fonts;
                script-src 'self' 'unsafe-inline' https://vercel.live/;
                connect-src 'self' https://vercel.live/ https://vitals.vercel-insights.com https://*.pusher.com/ wss://*.pusher.com/;
                img-src 'self' data: https://vercel.com/ https://vercel.live/;
                frame-src 'self' https://vercel.live/;
            `
        }
 
        // for production environment white-list vitals.vercel-insights
        // based on: https://vercel.com/docs/speed-insights#content-security-policy
        if (process.env.VERCEL_ENV === 'production') {
            return `
                ${defaultCSPDirectives}
                font-src 'self';
                style-src 'self' 'unsafe-inline';
                script-src 'self' 'unsafe-inline';
                connect-src 'self' https://vitals.vercel-insights.com;
                img-src 'self' data:;
                frame-src 'none';
            `
        }
 
        // for dev environment enable unsafe-eval for hot-reload
        return `
            ${defaultCSPDirectives}
            font-src 'self';
            style-src 'self' 'unsafe-inline';
            script-src 'self' 'unsafe-inline' 'unsafe-eval';
            connect-src 'self';
            img-src 'self' data:;
            frame-src 'none';
        `
 
    }
 
    const headers = [
        {
            key: cspReportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy',
            value: cspHeader().replace(/\n/g, ''),
        },
    ]
 
    return headers
 
}

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:

Tip

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

Tip

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.

Note

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

Note

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:

security.config.mjs
    // for dev environment enable unsafe-eval for hot-reload
    return `
        ${defaultCSPDirectives}
        font-src 'self';
        style-src 'self' 'unsafe-inline';
        script-src 'self' 'unsafe-inline';
        connect-src 'self';
        img-src 'self' data:;
        frame-src 'none';
    `

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.

Warning

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.

Note

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

Warning

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

Tip

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.

Note

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:

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:

next.config.mjs
const securityHeadersConfig = (phase) => {
 
    const cspReportOnly = true
 
    const reportingUrl = 'INSET_YOUR_SENTRY_REPORT_URI_HERE'
    const reportingDomainWildcard = 'https://*.ingest.us.sentry.io'
 
    const cspHeader = () => {
 
        const upgradeInsecure = (phase !== PHASE_DEVELOPMENT_SERVER && !cspReportOnly) ? 'upgrade-insecure-requests;' : ''
 
        // reporting uri (CSP v1)
        const reportCSPViolations = `report-uri ${reportingUrl};`
 
        // worker-src is for sentry replay
        // child-src is because safari <= 15.4 does not support worker-src
        const defaultCSPDirectives = `
            default-src 'none';
            media-src 'self';
            object-src 'none';
            worker-src 'self' blob:;
            child-src 'self' blob:;
            manifest-src 'self';
            base-uri 'none';
            form-action 'none';
            require-trusted-types-for 'script';
            frame-ancestors 'none';
            ${upgradeInsecure}
        `
 
        // when environment is preview enable unsafe-inline scripts for vercel preview feedback/comments feature
        // and whitelist vercel's domains based on:
        // https://vercel.com/docs/workflow-collaboration/comments/specialized-usage#using-a-content-security-policy
        // and white-list vitals.vercel-insights
        // based on: https://vercel.com/docs/speed-insights#content-security-policy
        if (process.env.VERCEL_ENV === 'preview') {
            return `
                ${defaultCSPDirectives}
                font-src 'self' https://vercel.live/ https://assets.vercel.com https://fonts.gstatic.com;
                style-src 'self' 'unsafe-inline' https://vercel.live/fonts;
                script-src 'self' 'unsafe-inline' https://vercel.live/;
                connect-src 'self' https://vercel.live/ https://vitals.vercel-insights.com https://*.pusher.com/ wss://*.pusher.com/ ${reportingDomainWildcard};
                img-src 'self' data: https://vercel.com/ https://vercel.live/;
                frame-src 'self' https://vercel.live/;
                ${reportCSPViolations}
            `
        }
 
        // for production environment white-list vitals.vercel-insights
        // based on: https://vercel.com/docs/speed-insights#content-security-policy
        if (process.env.VERCEL_ENV === 'production') {
            return `
                ${defaultCSPDirectives}
                font-src 'self';
                style-src 'self' 'unsafe-inline';
                script-src 'self' 'unsafe-inline';
                connect-src 'self' https://vitals.vercel-insights.com ${reportingDomainWildcard};
                img-src 'self';
                frame-src 'none';
                ${reportCSPViolations}
            `
        }
 
        // for dev environment enable unsafe-eval for hot-reload
        return `
            ${defaultCSPDirectives}
            font-src 'self';
            style-src 'self' 'unsafe-inline';
            script-src 'self' 'unsafe-inline' 'unsafe-eval';
            connect-src 'self';
            img-src 'self' data:;
            frame-src 'none';
        `
 
    }
 
    const headers = [
        {
            key: cspReportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy',
            value: cspHeader().replace(/\n/g, ''),
        },
    ]
 
    return headers
 
}

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

Note

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

Note

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.

Tip

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:

next.config.mjs
    // security headers for preview & production
    const extraSecurityHeaders = []
 
    if (phase !== PHASE_DEVELOPMENT_SERVER) {
        extraSecurityHeaders.push(
            {
                key: 'Strict-Transport-Security',
                value: 'max-age=31536000', // 1 year
            },
        )
    }
 
    const headers = [
		...extraSecurityHeaders,
		{
			key: cspReportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy',
			value: cspHeader().replace(/\n/g, ''),
		},
		{
			key: 'Referrer-Policy',
			value: 'same-origin',
		},
		{
			key: 'X-Content-Type-Options',
			value: 'nosniff',
		},
		{
			key: 'X-Frame-Options',
			value: 'DENY'
		},
    ]
 
    return 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:

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