Introduction
Content Security Policy (CSP) is a powerful but sometimes confusing tool to secure self-hosted and SaaS web applications alike. In this blog post I hope to illuminate some challenges and possible soultions dependent on specific situations. One such situation is if you’ve already built and deployed a complex web application without a CSP. Tough news is it’s going to be challenging to implement… but we are going to target that specific situation.
What is CSP?
Content Security Policy (CSP) is a security standard introduced to help prevent cross-site scripting (XSS), clickjacking, and other code injection attacks on web applications. CSP works by allowing site owners to specify which sources of content are trusted and can be loaded by the browser. This is done by setting a special HTTP header (Content-Security-Policy
) that defines rules for loading scripts, styles, images, and other resources.
If your site lacks a CSP it loses a critical layer of defense in depth. You should attempt to prevent XSS through input sanitization but CSP helps as a fallback if sanitization fails.
For a comprehensive overview, see the MDN Web Docs: Content Security Policy (CSP) and the CSP: HTTP header documentation.
So you’ve already built a site…
If you’ve already built a site without a CSP, the best way to start implementing a CSP is by using the Content-Security-Policy-Report-Only
header. This allows you to make policy changes without impacting the users of your site while collecting errors through the report-to
directive.
A starting policy would be something like this:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; report-uri /csp-report-endpoint
However this policy will prevent the usage of inline scripts. You will need to move them to a hosted .js
file, use allow hashes, or nonces to allow execution. If you are trying to get a CSP up and running as soon as possible, the easiest (yet brittle) way is to use hashes. You can hash the JS content within the <script>
tags and add that to the script-src directive like this:
script-src 'self' 'sha256-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc890defg';
Nuance and challenges
I already noted that this can be a very brittle approach to applying CSP, that is because everytime you update the code within the script tags, you will need to update the hash. This can become very difficult to maintain overtime. Sometimes this is acceptable if certain script tags are rarely changed or you use a React framework which bootstraps from one inline script.
Another challenge which you may need to take into account is the fact that hashes don’t work with event handlers like onclick
or onhover
. To allow these with hashes you must add another option to your CSP, unsafe-hashes
. See more about these nuances here on MDN.
The better route - CSP Nonces
A more robust and maintainable approach to enabling inline scripts is to use CSP nonces. A nonce is a random value generated for each HTTP response, which is then included in both the CSP header and as an attribute on allowed <script>
tags. This allows only scripts with the correct nonce to execute, reducing the risk of XSS while avoiding the brittleness of hashes.
To implement nonces, you typically use middleware in your web framework to:
- Generate a cryptographically secure random nonce for each request.
- Attach the nonce to the CSP header, e.g.:
Content-Security-Policy: script-src 'self' 'nonce-<random-value>';
- Inject the same nonce as a
nonce
attribute into your inline<script>
tags:<script nonce="random-value"> // Your inline JS here </script>
Example: Express.js Middleware
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
"Content-Security-Policy",
`script-src 'self' 'nonce-${nonce}';`
);
next();
});
Then, in your template:
<script nonce="{{nonce}}">
// Inline JS
</script>
Example: Using Helmet.js middleware
You can also use Helmet.js to set CSP headers with nonces in Express. Here’s how you can do it:
const helmet = require("helmet");
const crypto = require("crypto");
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(
helmet.contentSecurityPolicy({
useDefaults: true,
directives: {
"script-src": [
"'self'",
(req, res) => `'nonce-${res.locals.nonce}'`
],
// add other directives as needed
},
})
);
In your templates, add the nonce to your inline scripts:
<script nonce="{{nonce}}">
// Inline JS here
</script>
This ensures only scripts with the correct nonce will execute, providing strong protection against XSS.
This approach allows you to keep inline scripts without updating hashes every time the code changes, while maintaining strong security guarantees.
Conclusion
Implementing a Content Security Policy can be challenging, especially for existing applications, but it is a crucial step in strengthening your site’s security posture. Whether you start with a report-only mode, hashes, or move directly to nonces, each approach offers a path toward reducing the risk of XSS and other injection attacks. By understanding the trade-offs and leveraging tools like Helmet.js, you can incrementally adopt CSP and protect your users without sacrificing maintainability. Start small, monitor violations, and iterate—your application’s security will be better for it.