Setting up Google Tag Manager in a Nextjs application with a strict content security policy

In this article we will cover:

  1. What is the problem between having a strict CSP and using Google Tag Manager (GTM)
  2. What is a nonce and how to generate it?
  3. How to use a nonce to allow an inline script to be executed
  4. How to pass down the nonce to GTM so we can inject inline scripts at runtime and executed without bypassing the CSP

Introduction

Google Tag Manager (GTM) is a tag management system that allows you to quickly and easily update measurement codes and related code fragments collectively known as tags on your website or mobile app. In simpler words, it allows you to execute different code snippets in your website without making changes to the source code. This is great when you work on a company that has a marketing department that wants to try different pixel tracking or retargeting tools or measure different user actions, because once set up in the website, GTM allows them to do that directly from GTM's UI without a need of a code change or deployment.

The problem with GTM is that every fragment of code that you specify to execute in the website will be inlined and if you have a Content Security Policy (CSP) that is somewhat strict (and i hope you do) all inline scripts will be blocked.

Enter nonce

The CSP specification defines a way to indicate that an inline script is to be trusted, which is by a way o using a nonce or a single use token. If the inline script contains a nonce attribute matching the nonce specified in the CSP header the script can be executed safely.

It's important to mention that there are other ways in the CSP specification to indicate that an inline is to be trusted, the most common use is via a hash where the inline script can be executed if its hash matches the specified hash in the header. The problem with this approach is that you need to know the inline scripts at build time so you cna hash them and that won't work with scripts loaded via GTM.

Generating the nonce

The nonce should be a secure random string, and should not be reused, and in the context of Nextjs, it must be generated either during SSR (server-side rendering) or during SSG (static site generation) so the browser is able to parse the CSP content with the nonce in it. In Nextjs, the place to execute code on a page during the server phase and not the client is _document.js

import { randomUUID } from 'crypto'
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
    const nonce = randomUUID()
    return (
        <Html lang="en">
            <Head />
            <body>
                <Main />
                <NextScript />
            </body>
        </Html>
    )
}

Loading GTM

To load the GTM library we can just use the snippet GTM provides with two very important changes.

Before we talk about those changes, it's important to understand how the snippet provided by GTM works. GTM provides an inline script that when executed it injects another inline script that will load the GTM's library and execute it and, important to the topic at hand, both inline scripts have to be allowed by the CSP.

Now that we know that we need to accomplish, these are the changes we need to do to the loading snippet:

  1. we need to add a nonce attribute with the value that was just generated, so the script to load GTM's library can be executed
  2. we need to pass down the nonce value to the inline script created by the first inline script (this is normally referred to as being nonce-aware)
import { randomUUID } from 'crypto'
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
    const nonce = randomUUID()
    return (
        <Html lang="en">
            <Head>
                <>
                    <script
                        nonce={nonce} // Change #1
                        id="gtmScript"
                        dangerouslySetInnerHTML={{
                            __html: `<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;

                            /* Change #2 */
                            var n=d.querySelector('[nonce]'); n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));
                            
                            f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','YOUR_GTM_ID');</script>`,
                        }}
                    />
                </>
            </Head>
            <body>
                <Main />
                <NextScript />
            </body>
        </Html>
    )
}

Loading scripts via GTM

Now that we loaded GTM's library securely, we need to find a way to make GTM aware of the nonce value so it can be passed down to any script loaded via GTM. At this point we leave the Nextjs world and enter GTM's (i'll focus on the setup needed but i won't go deep into the GTM specifics as there's plenty of documentation and i am really no expert on it).

First of all, we need to create a variable that has the ability to get the nonce value that was generated when the page was rendered. We can achieve that by creating a DOM element variable type and specifying from which DOM element it needs to pull the value from (any script tag with the nonce attribute will do)

Now, when you want to include a script via GTM, you just need to create a Custom HTML type tag with any code you want (loading Facebook Pixel tracking library for example) and you need to add a nonce attribute to it

<script nonce={{nonce}}>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
var a=b.querySelector('[nonce]');a&&t.setAttribute('nonce',a.nonce||a.getAttribute('nonce'));
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_FB_ID');
fbq('track', 'PageView');
</script>

Two important things to note:

  1. the double curley braces syntax in the nonce attribute is the way to interpolate a GTM variable's value. That means that when this code is injected, the nonce attribute will actually contain the value that was generated when the page was rendered
  2. you may need to make the script you're injecting nonce-aware the same way we did before with the GTM script if, for example, the script you're injecting will actually inject a second script. In the previous example, you can see this line is doing exactly that
var a=b.querySelector('[nonce]');a&&t.setAttribute('nonce',a.nonce||a.getAttribute('nonce'));

Drawbacks

I really like this approach, as it doesn't require to know all the scripts during build time, giving you the flexibility of loading scripts during runtime. It is important though to be aware that this has its own negative side, as it's allowing anyone with access to GTM panel to execute code in the website. Be aware of the risk and decide whether or not it's worth it.

Closing words

We've covered what is a nonce and why is important, how to create it and how to use it to allow an inline script to be executed. Furthermore, we've covered how to use GTM to inject inline scripts during runtime and executed them securely, without bypassing the CSP.

Months ago, when i had to deal with this situation, i struggle to find a simple way to make GTM work properly without compromising security. I hope i did a good job explaining the whole process that you have find this article useful. If you'd like me to explain further any of the decisions i made here or any other topic, feel free to contact me!

Full snippet

Here's a full snippet with everything that GTM needs in order to be fully integrated and ready to use

import { randomUUID } from 'crypto'
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
    const nonce = randomUUID()
    return (
        <Html lang="en">
            <Head>
                <>
                    <script
                        nonce={nonce}
                        id="GTMDataLayerSetup"
                        dangerouslySetInnerHTML={{
                            __html: `window.dataLayer = window.dataLayer || [];`,
                        }}
                    />
                    <script
                        nonce={nonce}
                        id="gtmScript"
                        dangerouslySetInnerHTML={{
                            __html: `<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
                            new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
                            j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
                            'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
                            n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
                            })(window,document,'script','dataLayer','YOUR_GTM_ID');</script>`,
                        }}
                    />
                </>
            </Head>
            <body>
                <Main />
                <NextScript />
            </body>
        </Html>
    )
}