Content Security Policy and Subresource Integrity solve different problems, and they work best when you treat them as a pair.
CSP answers: what sources am I willing to trust?
SRI answers: is this exact file the one I meant to load?
That distinction matters. I still see teams deploy one and assume they’re covered. They’re not.
If you load a third-party script from an allowed domain in CSP, CSP is happy. But if that third-party file changes unexpectedly, CSP won’t help. SRI will.
If you use SRI without CSP, the browser can verify the file hash, but your page may still allow random inline scripts, unsafe eval, or script injection from places you never meant to trust. CSP fixes that.
Use both.
The short version
- CSP restricts where resources can come from and how scripts can execute.
- SRI verifies the cryptographic hash of external JS or CSS files.
- Together, they reduce supply-chain risk and script injection risk.
What SRI looks like
Here’s the classic example:
<script
src="https://cdn.example.com/app.8f3c1.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GhEXAMPLEHASH1234567890"
crossorigin="anonymous"></script>
And for CSS:
<link
rel="stylesheet"
href="https://cdn.example.com/app.4a21d.css"
integrity="sha384-H8BRh8j48xEXAMPLEHASH0987654321abc"
crossorigin="anonymous">
The browser downloads the file, hashes it, and compares it to the integrity value. If the bytes don’t match, the browser blocks it.
That’s exactly what you want for third-party CDNs and any static asset that should never change unexpectedly.
What CSP looks like
A minimal CSP for a self-hosted app might look like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-rAnd0m123';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
That policy says:
- default to same-origin only
- allow scripts from self and inline scripts only if they carry the right nonce
- block plugins with
object-src 'none' - prevent clickjacking with
frame-ancestors 'none'
If you want ready-made policy patterns, csp-examples.com is handy for quick starting points.
Why CSP and SRI are not interchangeable
Here’s the real-world split:
CSP protects against:
- injected
<script src="https://evil.com/x.js"> - inline script injection when nonces or hashes are enforced
- loading assets from unapproved origins
- dangerous execution patterns like
unsafe-inlineandunsafe-evalif you avoid them
SRI protects against:
- a compromised CDN asset
- a silently changed third-party file
- corrupted or tampered static assets in transit or at origin
CSP says “only scripts from cdn.example.com.”
SRI says “and only this exact script from cdn.example.com.”
That’s why they fit together so well.
Copy-paste example: CSP + SRI for a CDN script
This is the pattern I recommend when you must load a third-party script directly from a CDN.
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CSP + SRI Demo</title>
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
">
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js"
integrity="sha384-REPLACE_WITH_REAL_HASH"
crossorigin="anonymous"></script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>
Header version
Don’t ship CSP in a meta tag unless you have to. Use the HTTP header.
Nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" always;
Apache
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none';"
How to generate an SRI hash
Use OpenSSL:
curl -s https://cdn.example.com/app.js \
| openssl dgst -sha384 -binary \
| openssl base64 -A
Then prepend the algorithm:
sha384-<base64-output>
If you already have the file locally:
openssl dgst -sha384 -binary app.js | openssl base64 -A
For build pipelines, I’d automate this instead of hand-generating hashes. Most modern bundlers and asset pipelines can emit SRI attributes during build.
A practical deployment pattern
The cleanest setup usually looks like this:
- Self-host your own app bundle
- Use CSP nonces for dynamic inline bootstrapping only if necessary
- Use SRI for any external CDN-hosted JS or CSS
- Keep third-party domains in CSP as narrow as possible
Example:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123' https://cdn.example.com;
style-src 'self' https://cdn.example.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
<script nonce="abc123">
window.appConfig = { apiBase: "/api" };
</script>
<script
src="https://cdn.example.com/widget.min.js"
integrity="sha384-REPLACE_ME"
crossorigin="anonymous"></script>
That gives you:
- CSP control over allowed origins and inline execution
- SRI verification for the third-party widget
Where people mess this up
1. Allowing a wide domain in CSP and skipping SRI
This is the classic “we trust the CDN” mistake.
script-src 'self' https://cdn.example.com
That’s better than nothing, but if the CDN asset changes, you’re trusting whatever is served.
Add SRI.
2. Using SRI on files that change constantly
If your file changes every deploy and you don’t update the hash, the browser will block it. That’s expected.
SRI works best with:
- versioned filenames
- immutable assets
- external libraries
- CDN-hosted static files
3. Forgetting crossorigin="anonymous"
For cross-origin SRI, you usually need:
crossorigin="anonymous"
Without it, browsers can fail the integrity check or handle the request differently than you expect.
4. Thinking SRI protects inline scripts
It doesn’t.
For inline scripts, CSP nonces or CSP hashes are the right tool:
script-src 'self' 'nonce-abc123'
or
script-src 'self' 'sha256-abc...'
5. Keeping unsafe-inline in script-src
If your script-src still includes unsafe-inline, you’ve kneecapped one of the best parts of CSP.
For scripts, avoid it.
For styles, teams often keep unsafe-inline longer than they should. It’s common, but still worth cleaning up over time.
Real header example
Here’s a real CSP header from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-MWZiMTM1ZmItZGJkNS00OThhLTk4NGMtYmUwYmZkMzc4NWU2' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://app.tallytics.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
A few things I like about it:
object-src 'none'frame-ancestors 'none'base-uri 'self'- a nonce-based
script-src strict-dynamic, which is powerful when you’re managing trusted bootstrap scripts carefully
A few practical observations:
style-src 'unsafe-inline'is common in the real world, especially with consent managers and tag tooling, but it’s still a compromise.- Analytics and consent platforms expand your CSP fast. That’s normal, but every third-party domain should be there for a reason.
- This is exactly the kind of setup where SRI can still add value for any stable external scripts you directly include in markup.
Best-practice template
If you want a sane baseline, start here:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
style-src 'self';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
And for external static assets:
<link
rel="stylesheet"
href="https://cdn.example.com/lib.min.css"
integrity="sha384-REPLACE_WITH_REAL_HASH"
crossorigin="anonymous">
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-REPLACE_WITH_REAL_HASH"
crossorigin="anonymous"></script>
My rule of thumb
- If it’s inline, CSP nonce or CSP hash.
- If it’s external, CSP allowlist plus SRI.
- If it’s third-party, be extra suspicious.
- If you can self-host, do that first.
That last point is boring, but boring security wins. The fewer third parties you depend on, the simpler your CSP gets and the less you need to trust someone else’s deploy pipeline.
Quick checklist
Use this before shipping:
- CSP sent as an HTTP header
-
object-src 'none' -
base-uri 'self' -
frame-ancestors 'none' - no
unsafe-inlineinscript-src - nonces or hashes for inline scripts
- SRI on external CDN JS/CSS
-
crossorigin="anonymous"on cross-origin SRI assets - third-party origins trimmed to the minimum
- report-only policy tested before enforcement if you’re tightening an existing app
CSP and SRI aren’t competing controls. They cover each other’s blind spots. That’s why I deploy them together whenever I can.