7. 11. 2023
5 min read
Level up your web analytics and security with GTM and CSP in Next.js
Learn how to integrate Google Tag Manager (GTM) and Content Security Policy (CSP) with Nonce to boost your website's security and performance. GTM simplifies marketing tag management, while CSP safeguards against online threats. We'll walk you through the implementation process, covering everything from nonce generation to setting up CSP headers, all within the Next.js framework. Along the way, we'll tackle potential challenges and offer valuable insights to help you fine-tune your configuration, achieving an optimal balance between performance and security.
Martin Naščák
Software Engineer
What is GTM
Google Tag Manager (GTM) is a tool that enables you to install, store, and manage marketing tags without modifying website code.
With GTM, you can easily add tracking codes for various marketing and analytics platforms such as Google Analytics, Google Ads, and many others, without the need for a developer to manually add the code to each page on your website, making it easier to track user behavior and optimize your website's performance.
In GTM we can define triggers that will run a small piece of code on our website, usually, it is for some third-party services. This code uses nonce for security.
What is Content Security Policy (CSP)
In simple terms, CSP is a security mechanism that helps to prevent cross-site scripting (XSS) attacks and other code injection attacks on web applications. It allows website administrators to specify which sources of content (such as scripts, images, and other resources) are allowed to be loaded on their site, and which are not.
Why is it important for us?
As our website relies also on multiple third-party applications, we are using inline scripts and other scripts that load on our page. To make our page secure we must validate all these scripts to prevent any XSS attack. CSP allows us to set strict rules that will allow us to run scripts only from specific websites. We can set headers to define what origins can trigger scripts on our website. For in-line scripts we have multiple methods, the best one is using nonce or hash the script.
For GTM implementation we must use nonce because inside the GTM we can add multiple new inline scripts that will trigger on for example GA4 events if needed or any other events we use. We will parse nonce to inline scripts and the tag manager will be able to run them.
What’s Nonce
Nonce is a randomly generated base64-encoded string of at least 128 bits that re-generates every time you visit the website, this will ensure that the attacker can't predict the string and use it for his scripts. This nonce script is passed to GTM and allows GTM to run in-line scripts. The nonce must be different and unique for every user.
Implementation
Prerequisites:
You must have a Google account and create a tag manager on Google.
After creating a new Google tag you should be able to obtain GTM code in this format ``GTM-XXXXXX`
We need to generate a unique nonce for ****every page load (we will talk about it later)
We need to allow the following rules for CSPs (Headers):
Content-Security-Policy: script-src 'nonce-{SERVER-GENERATED-NONCE}'; img-src www.googletagmanager.com
Injecting an inline script to the header
We must inject the following code snippet into our header to load the tag manager on our website.
(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','GTM-XXXXXX');
We must modify GTM-XXXXXX to use our GTM code from tag manager.
- })(window,document,'script','dataLayer','GTM-XXXXXX');+ })(window,document,'script','dataLayer','GTM-123456'); // <- Use GTM-ID from tag manager
After injecting this snippet, our GTM should be working on our website.
The script above is using querySelector var n=d.querySelector('[nonce]');
to acquire nonce from our page and apply it to the GTM script.
This is how it looks in our _app.tsx
import { useCookie } from 'react-use'...
const CustomApp = ({ Component, pageProps, err, token }: AppProps) => { ... const [nonceCookie] = useCookie('nonce') // We are storing nonce to cookies ...
{/* Google Tag Manager */}<Script nonce={nonceCookie} // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ __html: GTM_SCRIPT, // GTM_SCRIPT is the function above }}/>
Middleware implementation
To create middleware in Next.js you need to create a file with the name middleware.ts
in your root directory of Next app or middleware/index.ts
Be careful, middleware/index.ts
has higher priority so if you create both, it will use this one.
Our entry point for middleware is the following. We filter out all network requests except the page. This filter out is for nonce only, we still want to apply our headers to most requests.
middleware.ts
import { NextConfig } from 'next'import { NextRequest, NextResponse } from 'next/server'import { contentSecurityPolicyHeaderMiddleware } from './middlewares'
export const middleware = (request: NextRequest) => { const response = NextResponse.next() const generateNonce = request.headers.get('purpose') !== 'prefetch' && request.headers.get('x-nextjs-data') !== '1'
return generateNonce ? contentSecurityPolicyHeaderMiddleware(request, response, generateNonce) : response}
export const config: NextConfig = { matcher: '/((?!.*\\.).*)', unstable_allowDynamic: [ '/node_modules/lodash/**', // use a glob to allow anything in the function-bind 3rd party module ],}
Generating a nonce
We generate nonce with the following code, then we set nonce in cookies so we can access it on the client side.
nonce = crypto.randomUUID().replaceAll('-', '')
In the method getCSPHeader, we just fetch data from our API (Request all trusted sources) and we return a string that will be used for CSP with all trusted domains, nonce, and hashed inline scripts.
export const contentSecurityPolicyHeaderMiddleware = async ( request: NextRequest, response: NextResponse, generateNonce: boolean) => { let nonce = request.cookies.get('nonce') if (!nonce || generateNonce) { nonce = crypto.randomUUID().replaceAll('-', '') const cspHeader = await getCSPHeader(nonce) response.cookies.set('nonce', nonce) response.headers.set('Content-Security-Policy', cspHeader) } return response}
Problems with CSP and Nonce
We experienced several problems implementing CSP and Nonce. One of the requirements was to have trusted sources in the admin panel, so we could change them if needed without changing our code.
Before this request we had all our CSP content and trusted sources defined in <meta>
tag however _document.js
where it was defined, renders only on the server, and then it is cached, so its content can't be dynamically changed. The only way that we find feasible for our use case with Next.js is to use middleware. Middleware runs at every request or pre-fetch we do on the page. This way we can reliably generate unique nonce for every page load for every user.
We created a middleware that generates a nonce, fetching trusted domains from the database and setting up headers. So far so good, but not so fast!
How middleware triggers on Next.js
Middleware triggers on every network request you do, which means for each image you load or for each pre-fetch you hover on, it will trigger the middleware. This can be a big issue and a significant performance bottleneck. It would re-generate nonce multiple times which can make GTM not working correctly.
Solution
The behavior of how middleware triggers on our website can be modified. Next.js provide a way to filter out request that we want to ignore using RegExp.
This is a snippet of our middleware entry point. The matcher
is our filter.
matcher
does not filter out prefetch or next-js data requests, we still need to apply CSP for these requests, but we won't generate nonce. This is why we create a helper boolean that will check if the request is a prefetch or next-js data request.
export const middleware = (request: NextRequest) => {.... const generateNonce = request.headers.get('purpose') !== 'prefetch' && request.headers.get('x-nextjs-data') !== '1'
...
export const config: NextConfig = { matcher: '/((?!.*\\.).*)', ...}
What else can possibly go wrong with Next.JS Middleware
There is another issue that surprised us. Middleware changes how routing works.
If you are using Shallow Routing on your page, with middleware it will work differently or not at all!
Also, do not forget that Next.JS middleware runs in an “edge runtime” environment, which is not the same as node or web, so it does not support everything that other environments do.
In production, the Edge Runtime uses the JavaScript V8 engine(opens in a new tab), not Node.js, so there is no access to Node.js APIs.
Not every library works on edge. While developing middleware, test with the built site from time to time.
You might
also like