Understanding and implementing a proper Content Security Policy with Next.js and Styled Components

Rees Morris

Rees Morris ยท 23 Apr, 2021

Update Jan 2022: Due to the popularity this article seems to have gathered, I've re-written huge parts of it to make it even more informative.

Introduction

This post aims to discuss the best ways to implement a secure and scalable Content Security Policy on a website, along with some theory as to just what a Content Security Policy (CSP) actually is. We'll be using Next.js and styled-components for the code here, though the theory should be easily transferable to any other library.

Depending on the architecture of your site, you may not be able to implement a wholly complete Content Security Policy (eg. certain aspects require you to be running a web server). As such, the first few sections of this post should apply to virtually any website, and it's strongly encouraged to have at least a baseline policy in place.

Before we begin, I must emphasise that I am not a security expert; I'm just a frontend developer who was tasked with implementing this a few months back and am sharing everything I learned here. The ideas and suggestions below are things found on my own journey using the scarce amount of internet resources on the topic, so don't assume this is the only / the best way to approach CSP!

Content Security What?

The purpose of the Content Security Policy is to add a layer of protection between a website and browser by blocking specific directives that don't align with your policy.

In short, CSP essentially forces you to 'whitelist' the origins that resources on your site (such as scripts, styles, fonts, images, etc.) can load from. If a resource is being requested from an origin that isn't on the whitelist, it will be blocked by the browser. This makes CSP an excellent layer of defence against XSS and data injection attacks.

CSP is typically added to a site in one of two ways: either as a HTTP header returned by the web server, or as a meta tag element embedded within the head. Whilst the HTTP header is more common, it mostly depends on your setup: some applications (such as completely static sites) don't have any server-side rendering, so will need to rely on the meta tag instead. Both are just as valid!

CSP Syntax

The syntax of a Content Security Policy looks like the following:

Content-Security-Policy: <directive> <values>; <directive> <values>;
<meta
  http-equiv="Content-Security-Policy"
  content="<directive> <values>; <directive> <values>"
/>

Within the syntax, we have directives and values. The directive is declared first, and then its value(s) afterwards. A directive can have any number of values, each separated by a space.

We can declare as many directives as we need, each separated by a semicolon after the last value of the previous directive.

  • The directives relate to the different resources on our site (scripts, styles, fonts, images, etc.). For example, to specify where our images can be loaded from, we would use img-src.

  • The values tell the browser which origins are allowed for that directive. Each value is separated by a space. This typically consists of a URI string, but can also include predefined keywords, which must be wrapped in 'single quotes'.

Let's look at the Twitter website as an example. They use the HTTP response header method of setting a policy, and a cherry-picked snippet of it looks like this:

content-security-policy:
  font-src 'self' https://*.twimg.com;
  img-src 'self' blob: data: https://*.cdn.twitter.com https://ton.twitter.com;
  object-src 'none';
  script-src 'self' 'nonce-ZDZiNGQyMjMtYTQ4YS00MmUyLWJiZjUtNzI0MjA0OTdiMzYy';
  default-src 'self';
  • the font-src directive is using the 'self' keyword; which allows fonts hosted on the same origin to be loaded, as well as fonts from any subdomain on twimg.com
  • the img-src directive also uses the 'self' keyword; allowing images hosted on the same origin to load, as well as any blob: and data:URIs (arguably insecure), and any images loaded on a subdomain of cdn.twitter.com or on ton.twitter.com
  • the object-src directive is using the 'none' keyword which means that anything using this directive will be blocked; there is no allowed implementation
  • the script-src directive also uses the 'self' keyword, allowing scripts hosted on the same origin to load, as well as the nonce- keyword, which holds a random, cryptographically secure token that can be attached to inline elements (scripts, in this case); we'll learn much more about this later
  • the default-src directive is also set to self; we'll learn more about this in just a moment

It may be useful to know that the mapping of elements to directives is handled by the browser: if we add an <img src="" /> element to our site, the browser will always validate the src value against the img-src directive; we can't tell the browser to use something different.

Starting from the bottom

Arguably one of the most important CSP directives is the default-src directive. This directive acts as the fallback if a specific directive is needed, but missing.

For instance, looking at the Twitter example above, let's say we add an iframe to our site. The browser would first check for a frame-src directive to compare the iframe's src value to. In the event the directive doesn't exist, the browser will fall back to the default-src directive instead, and will follow whatever rules are set there. In this case, our iframe would only load if the src was the same origin, due to the default-src 'self' declaration.

This can be a blessing and a curse. Whilst it will save us time in the long run from having to declare dozens of different directives which all just use the 'self' keyword, it also opens up a vulnerability for our site: every undeclared directive will indirectly behave based on our default-src directive's values.

As such, the Mozilla Observatory and CSP Quick Reference Guide both recommend setting default-src to 'none' and expanding other specific directives outwards when needed. With this mindset, if a directive isn't explicitly declared, it's blocked. It makes the site much more secure, and debugging a lot easier.

Implementing our CSP

Let's add a Content Security Policy to our site.

As above, we'll start with just a simple default-src 'none' declaration within a meta tag, and then begin expanding outwards until our site returns to its expected behaviour.

You can either add the tag to a custom _document file, or an existing component that imports next/head (such as an SEO component).

Be sure to place it above all other tags and scripts.

/* ... */
render() {
  return (
    <Html>
      <Head>
        <meta
          httpEquiv='Content-Security-Policy'
          content="default-src 'none'"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
import React from 'react';
import NextHead from 'next/head';

const Head = () => {
  return (
    <NextHead>
      <meta httpEquiv='Content-Security-Policy' content="default-src 'none'" />
    </NextHead>
  );
};

export default Head;

You'll of course need to include this in your _app page (or any component that wraps all pages, like a custom Page component).

When refreshing the site, you might notice that everything has broken. Let's see what's going on.

Breaking down CSP errors

Most browsers tend to provide extremely helpful information when it comes to why our elements are being blocked when a Content Security Policy is in place.

I'll be using the follow-along repository for this, so your order of errors may differ - though we'll be resolving them all anyway!

When looking at the browser console in Chrome, the first errors for me have all been collapsed into the following message:

Refused to load the script '<URL>' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'script-src-elem' was not explicitly set, so 'default-src' is used as a fallback.

From this message alone, we get some idea of what's happening. We know already that certain URLs have been blocked from executing, and we can see that they are part of the script-src-elem directive. When expanding the list of errors, it looks like they all come from the local _next/static directory - hosted within the same origin of our site (localhost).

We could whitelist our current origin (http://localhost:3000 in my case) along with our live domain (such as reesmorris.co.uk), but that can make things potentially unsafe if our domain ever changes, as well as a pain to manage if your site has regional domains. This is where the power of the self keyword comes in. Instead of having to directly declare the domains the user will be visiting the site on, the 'self' keyword simply represents just that: the current origin!

Following the error message in the browser, we know we just need to declare a script-src-elem directive with the 'self' keyword value to fix this error. Let's update our policy just as such:

<meta
  httpEquiv="Content-Security-Policy"
  content="default-src 'none'; script-src-elem 'self'"
/>

Reloading the page doesn't give us much more to see, but the errors have changed! This is because those scripts are now being allowed to run, which can lead to other errors.

The next error to appear is:

Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "default-src 'none'".

This error is telling us that a script was prevented from running because it uses the eval() function, which is blocked by default due to the high risk it poses.

This error isn't actually being thrown by your code (unless you do use the eval() function, in which case you should remove it and return here), but instead by the development environment of Next.js, which uses eval() "to generate and rebuild [eval source maps] during development".

Let's add a script-src directive with the unsafe-eval keyword and see what changes:

<meta
  httpEquiv="Content-Security-Policy"
  content="default-src 'none'; script-src-elem 'self'; script-src 'unsafe-eval'"
/>

Success(?)! The content of the page is now visible, but we've now got two problems to deal with. The first is that there seems to be a giant "Unhandled Runtime Error" message on the page, and the second is we've just added 'unsafe-eval' to our Content Security Policy; which we definitely don't want in production.

Looking at the console, there's a whole bunch more errors now - since a lot more things are running and then being blocked. Ignoring all styled-components related errors for now, this one stands out:

Refused to connect to 'ws://localhost:3000/_next/webpack-hmr' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback.

It looks like Webpack is requiring a connect-src directive to be present. All of the collapsed URLs again point solely to our localhost origin, so we can again use the 'self' keyword here:

<meta
  httpEquiv="Content-Security-Policy"
  content="default-src 'none'; script-src-elem 'self'; script-src 'unsafe-eval'; connect-src 'self'"
/>

Hooray! Our errors have gone, and all JavaScript and links on the development site are behaving as normal. We still don't have any styling, but we'll get to that after making our policy much safer.

Creating a CSP Generator function

We obviously don't want 'unsafe-eval' to be present in our production build, and we can probably remove connect-src in production too, since (in the follow-along example) only Webpack requires it. We could make a conditional that returns different strings, but that will make this extremely difficult to maintain.

Instead, let's make a function that 'generates' our CSP for us!

// utils/generate-csp.js
const generateCSP = () => {
  const policy = {};

  // adder function for our policy object
  const add = (directive, value, options = {}) => {
    if (options.devOnly && process.env.NODE_ENV !== 'development') return;
    const curr = policy[directive];
    policy[directive] = curr ? [...curr, value] : [value];
  };

  // default-src
  add('default-src', `'none'`);

  // script-src-elem
  add('script-src-elem', `'self'`);

  // script-src
  add('script-src', `'unsafe-eval'`, { devOnly: true });

  // connect-src
  add('connect-src', `'self'`, { devOnly: true });

  // return the object in a formatted value (this won't work on IE11 without a polyfill!)
  return Object.entries(policy)
    .map(([key, value]) => `${key} ${value.join(' ')}`)
    .join('; ');
};

export default generateCSP;
// utils/generate-csp.ts
type Directive = ''; /* See the Gist link below */
type Value = string;
interface Options {
  devOnly?: boolean;
}

const generateCSP = () => {
  const policy: Partial<Record<Directive, Value[]>> = {};

  // adder function for our policy object
  const add = (directive: Directive, value: Value, options: Options = {}) => {
    if (options.devOnly && process.env.NODE_ENV !== 'development') return;
    const curr = policy[directive];
    policy[directive] = curr ? [...curr, value] : [value];
  };

  // default-src
  add('default-src', `'none'`);

  // script-src-elem
  add('script-src-elem', `'self'`);

  // script-src
  add('script-src', `'unsafe-eval'`, { devOnly: true });

  // connect-src
  add('connect-src', `'self'`, { devOnly: true });

  // return the object in a formatted value (this won't work on IE11 without a polyfill!)
  return Object.entries(policy)
    .map(([key, value]) => `${key} ${value.join(' ')}`)
    .join('; ');
};

export default generateCSP;

I've included the Directive types in this Gist.

This is my personal preference for writing CSP declarations, but you're certainly free to modify this to your own style. The main idea was to declare one directive and value per line, so that we can easily refer to the file without having to scroll forever horizontally. The options object only takes one value, though seeing devOnly written next to each declaration makes it impossible to confuse.

Let's make sure our code is using it.

<meta httpEquiv='Content-Security-Policy' content={generateCSP()} />

Nothing should have changed for you, meaning it's time to fix the styling!

Inline styles

Let's have a look at the errors being thrown for our styled components:

Refused to apply inline style because it violates the following Content Security Policy directive: "default-src 'none'". Either the 'unsafe-inline' keyword, a hash ('sha256-...='), or a nonce ('nonce-...') is required to enable inline execution. Note also that 'style-src' was not explicitly set, so 'default-src' is used as a fallback.

This message is different to the previous ones, and there's more information to digest. It's essentially telling us that the browser blocked our inline styling because our style-src directive needs to include one of the following:

  • the 'unsafe-inline' keyword
  • a sha256 (or similar) hash
  • a nonce value

The first option is to use the 'unsafe-inline' keyword. As the name suggests, this method is considered unsafe as it will allow any inline styles to run, regardless of whether they were present when the site loaded or added in later by a malicious third party. It somewhat defeats the purpose of having a policy in place.

The second option is to generate a sha256 hash of our inline styles and pass the hash as one of the values for our style-src directive. This could work in theory, but if you're using any form of CSS-in-JS solution, just one piece of conditional rendering logic (eg. a user clicks a button, changing a colour) would break this; when the CSS of the inline style changes, it wouldn't match the hash anymore.

The third option is likely our best bet, a nonce value. A nonce is a randomly generated token used only once (Number Only used oNCE). The theory is that our server will generate this random token when the user visits the site, attach the nonce as a value to our style-src directive, and also pass the value to Next.js to attach it as a nonce='' attribute on our style tags.

This is where this post diverges depending on your infrastructure. In order to generate a nonce value, we need our site to be behind a server. More specifically, we need every page on our site to be server-side rendered at runtime by having getInitialProps() in a custom _app. This likely isn't possible if you have any kind of static site, such as this one.

Let's explore some options below.

Working without a server

If you don't have a custom _app with getInitialProps set up, you'll need to declare 'unsafe-inline' in your style-src directive for the site to work with styled components (or any library that creates inline styles). I think that is okay.

So long as we're talking exclusively about using 'unsafe-inline' in the context of styling and never for scripts, my opinion is that you should be fine. If your Content Security Policy is strong for all other directives (eg. img-src), there is very little that CSS-based XSS attacks can achieve.

If your site does allow user input, it should go without saying that sanitising HTML to prevent custom <style> tags or inline style attributes before storing/rendering it is crucial in mitigating even the least dangerous forms of attack.

// style-src
add('style-src', `'unsafe-inline'`);

For further relief (at the time of writing), both Twitter's report and Spotify's report on Mozilla Observatory both include 'unsafe-inline' for styling, but their overall scores couldn't be higher. We're always limited in some way by the architecture we choose, though it's clear that achieving a top-grade security score is doable regardless of how you're using Next.js.

Again, I am by no means a security expert so do conduct additional research if this is very important for you. Read on for information on using a nonce rather than 'unsafe-inline'.

(Optional) Inline scripts

If your site uses any inline scripts, this section discusses how to allow those through your Content Security Policy without using the 'unsafe-inline' keyword. If your site is completely server-side rendered (eg. a custom _app with getInitialProps) then you'll instead be able to use the nonce we generate later on, so feel free to skip this.

You may have noticed the same error as for inline styles. The one difference here is that we should never use 'unsafe-inline' for script directives, as that can have massive security implications (to a point where having CSP is arguably useless!).

Instead, let's create a helper function that will hash our inline scripts.

Note that you'll only need this for inline scripts; if you have any <script> tags with a src attribute, you won't need to hash those - but will need to allow their origins in your CSP. If you have any scripts of type="application/json", they also won't need hashing as the browser doesn't evaluate those.

// hash-inline-scripts.js
import crypto from 'crypto';

const hash = content =>
  crypto.createHash('sha256').update(content).digest('base64');

const hashInlineScripts = scripts => {
  const scriptHashes = scripts.map(script => {
    const content = script.props.dangerouslySetInnerHTML
      ? script.props.dangerouslySetInnerHTML.__html
      : script.props.children;
    return `'sha256-${hash(content)}'`;
  });
  return { scriptHashes, scripts };
};

export default hashInlineScripts;
// hash-inline-scripts.ts
import crypto from 'crypto';

const hash = (content: string) =>
  crypto.createHash('sha256').update(content).digest('base64');

const hashInlineScripts = (scripts: JSX.Element[]) => {
  const scriptHashes = scripts.map(script => {
    const content = script.props.dangerouslySetInnerHTML
      ? script.props.dangerouslySetInnerHTML.__html
      : script.props.children;
    return `'sha256-${hash(content)}'`;
  });
  return { scriptHashes, scripts };
};

export default hashInlineScripts;

This helper function will receive an array of <script> tags, hash their values, and return the original array. For all inline scripts we wish to embed in the site, we'll want to call this function before calling our generateCSP() function, like so:

const { scriptHashes, hashes } = hashInlineScripts([
  <script dangerouslySetInnerHTML={{ __html: `console.log('a')` }} />,
  <script dangerouslySetInnerHTML={{ __html: `console.log('b')` }} />
]);

We'll want to update our generateCSP() function to accept these hashes:

const generateCSP = ({ scriptHashes } = {}) => {
  /* ... */

  // script-src-elem
  add('script-src-elem', `'self'`);
  scriptHashes && scriptHashes.forEach(hash => add('script-src-elem', hash));

  /* ... */
};
interface generateCSPProps {
  scriptHashes?: string[];
}

const generateCSP = ({ scriptHashes }: generateCSPProps = {}) => {
  /* ... */

  // script-src-elem
  add('script-src-elem', `'self'`);
  scriptHashes && scriptHashes.forEach(hash => add('script-src-elem', hash));

  /* ... */
};

And, lastly, update our head to pass these hashes in, as well as render the scripts:

<Head>
  <meta
    httpEquiv='Content-Security-Policy'
    content={generateCSP({ scriptHashes })}
  />
  {scripts}
</Head>

Alas, our inline scripts should now be executing!

The only caveat is that the content of your inline script can't change whilst the user is on the site, as this will invalidate the hash that was generated and will be blocked from running. I believe this also won't work with next/script tags.

Generating a nonce server-side

Whilst the following process is arguably more secure than using 'unsafe-inline' for styling, you may not need it. The process is somewhat convoluted and may cost more time than it's worth. Nonetheless, let's go!

As mentioned above, we can only generate a nonce value on the server before passing it down to our frontend. As such, we'll need a custom _app for this. At the time of writing, Next.js Middleware has been released but there is no information on passing data from the middleware to the custom _app.

If you're using a custom server to wrap your Next.js frontend (such as Express), you'll still need to ensure every Next.js page is server-side rendered, so you'll still need a custom _app with getInitialProps.

The first step is to generate the random token. The MDN Docs recommend the nonce to be a "base64-encoded string of at least 128 bits of data from a cryptographically secure random number generator," so let's do just that by creating a util function:

// utils/generate-nonce.js
import crypto from 'crypto';

const generateNonce = () => {
  return crypto.randomBytes(16).toString('base64');
};

export default generateNonce;
// utils/generate-nonce.ts
import crypto from 'crypto';

const generateNonce = (): string => {
  return crypto.randomBytes(16).toString('base64');
};

export default generateNonce;

We're using the built-in crypto library here, though feel free to use any cryptographically-secure generator (such as the uuid library).

As mentioned earlier on, a Content Security Policy can be added to a site either as a HTTP header returned by the web server, or as a meta tag within the head. As we'll be generating the nonce on the server, we might as well have our entire middleware handle the CSP as well!

Let's remove the <meta httpEquiv='Content-Security-Policy' /> tag from our frontend โ€” we're going to move it to be a HTTP header instead.

For the next steps, we'll be generating the nonce in our _document file and returning it as a header. If you have a custom server set up (eg. Express), it might be more logical for you to send the Content-Security-Policy header from a middleware there instead. That's absolutely fine, though just be sure to pass the generated nonce to the frontend in the res.locals (or equivalent).

Let's add some lines to our _document file. You should have one set up already if you're using styled components, though you can copy the Custom Document template if needed.

Within the getInitialProps function:

// pages/_document.jsx
const nonce = generateNonce();
ctx.res.setHeader('Content-Security-Policy', generateCSP({ nonce }));
// pages/_document.tsx
const nonce = generateNonce();
ctx.res.setHeader('Content-Security-Policy', generateCSP({ nonce }));

You've likely noticed that our generateCSP function doesn't accept an argument for the nonce โ€” let's quickly fix that. If you followed the inline scripts section, you'll also accept an argument for scriptHashes here.

// utils/generate-csp.js
const generateCSP = ({ nonce }) => {
  /* ... */

  // script-src-elem
  add('script-src-elem', `'self'`);
  add('script-src-elem', `'nonce-${nonce}'`);

  // style-src
  add('style-src', `'nonce-${nonce}'`);
  add('style-src', `'unsafe-inline'`);

  /* ... */
};
// utils/generate-csp.ts
interface generateCSPProps {
  nonce?: string;
}

const generateCSP = ({ nonce }: generateCSPProps = {}) => {
  /* ... */

  // script-src-elem
  add('script-src-elem', `'self'`);
  add('script-src-elem', `'nonce-${nonce}'`);

  // style-src
  add('style-src', `'nonce-${nonce}'`);
  add('style-src', `'unsafe-inline'`);

  /* ... */
};

After reloading the site, something peculiar happens: our scripts still work fine, but the styling has disappeared again. We didn't remove 'unsafe-inline' from our CSP, so what gives? The developer console in Chrome provides a great explanation:

'unsafe-inline' is ignored if either a hash or nonce value is present in the source list.

As a result of adding the nonce to our CSP, our 'unsafe-inline' styling is no longer being rendered. Since we haven't yet linked the nonce with our inline styles, the browser doesn't trust any of them.

Certain libraries, including styled-components, allow for the nonce attribute to be provided via a __webpack_nonce__ variable, which is used primarily by webpack to inject the nonce attribute to any scripts loads. To add this to our frontend, we simply need to inject it via a <script> tag in the document head.

Back inside our custom _document file:

Just after the initialProps variable declaration, add:

const initialProps = await Document.getInitialProps(ctx);
const additionalProps = { nonce }; // ๐Ÿ‘ˆ add this

return {
  ...initialProps,
  ...additionalProps, // ๐Ÿ‘ˆ and this!
  styles: /* ... if using styled-components ... */
};

Within the render() method, before the return statement, add:

const { nonce } = this.props;

And within the <Head> tag, add:

<script
  nonce={nonce}
  dangerouslySetInnerHTML={{
    __html: `window.__webpack_nonce__ = "${nonce}"`
  }}
/>

Before the MyDocument class declaration, add:

interface DocumentProps {
  nonce: string;
}

Update the MyDocument class to extend this:

export default class MyDocument extends Document<DocumentProps>

Just after the initialProps variable declaration, add:

const initialProps = await Document.getInitialProps(ctx);
const additionalProps = { nonce }; // ๐Ÿ‘ˆ add this

return {
  ...initialProps,
  ...additionalProps, // ๐Ÿ‘ˆ and this!
  styles: /* ... if using styled-components ... */
};

Within the render() method, before the return statement, add:

const { nonce } = this.props;

And within the <Head> tag, add:

<script
  nonce={nonce}
  dangerouslySetInnerHTML={{
    __html: `window.__webpack_nonce__ = "${nonce}"`
  }}
/>

The site should now have some unique behaviour: the styling appears, but also seems to take a second to appear. If we disable JavaScript in our browser, the styling will be gone completely.

This is happening because our __webpack_nonce__ variable only exists on the window object, which doesn't exist on the server. As such, styled-components only pulls the value after the app rehydrates on the client side.

Unfortunately, there is no clean way of passing our nonce value directly through to styled-components yet. The previous method I suggested in this post last year seems to no longer work, though thankfully I've discovered a less-hacky (but still hacky) solution!

Just after the additionalProps variable declaration, add:

const sheetStyles = sheet.getStyleElement();
const style =
  (sheetStyles &&
    React.Children.map(sheetStyles, child =>
      React.cloneElement(child, {
        nonce
      })
    )) ||
  null;

Finally, pass the style variable into your return statement:

return {
  ...initialProps,
  ...additionalProps,
  styles: (
    <>
      {initialProps.styles}
      {style}
    </>
  )
};

Just after the additionalProps variable declaration, add:

const sheetStyles = sheet.getStyleElement();
const style =
  (sheetStyles &&
    React.Children.map(sheetStyles, child =>
      React.cloneElement(child, {
        nonce
      } as React.StyleHTMLAttributes<HTMLStyleElement>)
    )) ||
  null;

Finally, pass the style variable into your return statement:

return {
  ...initialProps,
  ...additionalProps,
  styles: (
    <>
      {initialProps.styles}
      {style}
    </>
  )
};

The site should now be fully styled, even with JavaScript disabled and before the client-side rehydration! Essentially, we're cloning the React <style> elements that styled-components is generating for us, but passing in our nonce value as an attribute.

And that's all there is to it! I hope this post has been insightful enough to help explain not only how to set strong Content Security Policy headers, but also what their purpose is for in general.

Go forth and make strong websites!

Bonus: Google Fonts

If you're using the 'follow-along' repository, or just have Google Fonts embedded into your website generally, you may notice the fonts are still not loading.

This is again due to the fact that we haven't whitelisted the domain that the fonts come from. Thankfully with CSP headers, we don't need to whitelist a specific URI, but can whitelist an entire domain (or subdirectory!) instead.

Taking a look at the console, the styling is missing because the Google Fonts domain is missing in our style-src directive. Let's add it to our generateCSP function:

add('style-src', 'https://fonts.googleapis.com');

After reloading, you'll notice another error related to a missing font-src directive value. Let's add that as well:

add('font-src', 'https://fonts.gstatic.com');

Notice that this time we didn't include the 'self' value? If you aren't hosting any fonts within the project itself (meaning no fonts will be loaded from the same frontend URL), there's really no need to add it.

And after reloading one final time, the fonts should appear!

Bonus 2: Maximising our CSP

The Mozilla Observatory recommends adding a few extra headers that we haven't yet specified in order to maximise the strength of our CSP.

The frame-ancestors directive can be used to prevent clickjacking, by limiting which origins will be able to embed ours in an <iframe> or similar.

add('frame-ancestors', `'none'`);

The base-uri directive limits which origins will be able to reference ours in a <base> element, which could be used to trick our site into loading untrusted scripts.

add('base-uri', `'none'`);

The form-action directive restricts which origins <form> elements are allowed to submit to, which can prevent sensitive data from being submitted to a malicious domain. This only affects the action attribute on forms (and not JavaScript that might call preventDefault()), so you'll only need to include 'self' (or another origin) here if your forms need to be able to submit without JavaScript.

add('form-action', `'none'`);

Bonus 3: Maximum security

I mentioned it slightly earlier, but Next.js has a great documentation page on Security Headers that can really increase a website's security rating (especially on the SecurityHeaders website).

For the most part, the list of options on the page can simply be copy/pasted into your next.config.js file. It's a small addition that can add some great security to your site.

It's also potentially possible to call your generateCSP function in this file instead, though it won't work if you need a nonce value, and you might need to re-code the function to be CommonJS-friendly since you can't use ES Modules there just yet.

And, just like that, we've got an amazingly strong Content Security Policy setup. Neat!