CSP for Static Sites and Jekyll
Table of Contents
Static sites have a nice security advantage: less moving parts, fewer server-side bugs, and usually a very predictable frontend. That predictability makes Content Security Policy a great fit.
The catch is that CSP gets awkward fast when you mix a static build system like Jekyll with inline scripts, analytics tags, consent banners, and hosting platforms that don’t give you full control over headers. I’ve seen plenty of “we added CSP” rollouts that either broke production or quietly fell back to 'unsafe-inline', which defeats half the point.
Here’s how I’d approach CSP for a static site or Jekyll project without turning the build into a science experiment.
Why CSP works well on static sites
CSP is strongest when your frontend is stable.
A Jekyll site usually has:
- a known set of templates
- a small number of JavaScript files
- predictable third-party dependencies
- fewer dynamic code paths
That means you can usually write a strict policy and keep it under control.
A good CSP helps block:
- injected inline JavaScript
- malicious third-party script execution
- risky plugin behavior
- data exfiltration through unexpected connections
- framing attacks
For static sites, the biggest challenge is not CSP itself. It’s all the little exceptions teams add over time: analytics, tag managers, cookie banners, embedded forms, and “just this one inline script.”
Start with a realistic policy
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-OGZjZDVlNGEtM2QxMi00NWVlLTkzNGMtMGFlYjg5ZjEyNTYz' '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://tallycdn.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'
This is a good example of a production CSP that reflects reality, not wishful thinking. It allows analytics, Cookiebot, websocket connections, and blocks framing with frame-ancestors 'none'.
Also notice the tradeoffs:
style-src 'unsafe-inline'is weaker than idealscript-srcuses a nonce andstrict-dynamic, which is much strongerobject-src 'none'is exactly what I want to see on modern sites
For a simple Jekyll site, I’d start smaller.
A solid baseline CSP for Jekyll
If your site is mostly static HTML, CSS, and a couple of local JS files, this is a good starting point:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
This does a few good things immediately:
- only loads resources from your own origin
- allows data URLs for inline images if needed
- blocks Flash-like legacy object embedding
- stops your pages being framed
- limits form submissions to your own site
If that policy breaks your site, don’t weaken everything at once. Find the exact directive causing the problem.
How to set CSP on a static site
Static sites don’t generate headers by themselves. Your hosting platform usually does.
Common patterns:
- Netlify:
_headers - Cloudflare: response header rules
- Nginx:
add_header - Apache:
Header set - GitHub Pages: harder, often needs a reverse proxy or CDN layer in front
For a Jekyll project deployed with Netlify, create a file called _headers in the site root:
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
If Jekyll copies that file into the final output, Netlify will apply it.
For Nginx:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
The always matters. Without it, some responses won’t carry the header.
Jekyll-specific problem: inline scripts
This is where static sites get annoying.
You’ll often find things like this in Jekyll layouts:
<script>
document.documentElement.classList.remove('no-js');
</script>
That breaks under script-src 'self' because inline script is blocked.
You have three main options:
1. Move inline code into a file
Best option most of the time.
<script src="/assets/js/site.js"></script>
// /assets/js/site.js
document.documentElement.classList.remove('no-js');
This keeps your policy simple:
script-src 'self'
I strongly prefer this unless there’s a very good reason not to.
2. Use a hash for fixed inline code
If the inline script is tiny and stable, a hash works well for static sites.
Example inline script:
<script>
document.documentElement.classList.remove('no-js');
</script>
Then add its SHA-256 hash to CSP:
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-jU/FVHIX5VyB10dESuLRptpSJ9cFvxonaQ+7rrypMjo='; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
Hashes are great for static content because the script doesn’t change per request.
The downside is maintenance. Change one character and the hash must be updated.
3. Use nonces
Nonces are excellent, but they fit dynamic rendering better than pure static hosting.
A nonce-based policy looks like this:
Content-Security-Policy: script-src 'self' 'nonce-rAnd0m123'; object-src 'none'; base-uri 'self'
And the matching script tag:
<script nonce="rAnd0m123">
document.documentElement.classList.remove('no-js');
</script>
For a truly static Jekyll build, nonces are awkward because a nonce should be unique per response. If your host can inject a fresh header and rewrite the HTML to match, great. Most static hosting setups can’t.
For Jekyll, I’d usually pick external JS or hashes before nonces.
Handling inline styles in Jekyll
Inline styles are another common source of CSP pain.
Bad but common:
<div style="display:none">Hidden</div>
If you don’t need it, remove it and use classes:
<div class="is-hidden">Hidden</div>
.is-hidden {
display: none;
}
Then keep:
style-src 'self'
If a third-party tool forces inline styles, you may end up with:
style-src 'self' 'unsafe-inline'
That’s not ideal, but sometimes it’s the practical choice. I’ll tolerate 'unsafe-inline' in style-src before I’ll tolerate it in script-src.
Third-party scripts: the policy grows fast
A clean static site CSP becomes messy the moment marketing shows up.
If you add Google Tag Manager, analytics, and Cookiebot, your policy starts looking a lot more like the headertest.com example:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
connect-src 'self' https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'
A few rules I follow here:
- add only the domains you actually need
- prefer directive-specific allowlists over bloating
default-src - keep
object-src 'none' - keep
base-uri 'self' - keep
frame-ancestors 'none'unless framing is intentional
If you need ready-made policy patterns, https://csp-examples.com is useful for comparing structures before you adapt them.
Make CSP configurable in Jekyll
I like keeping third-party origins in _config.yml or data files so the policy is easier to review.
Example _data/csp.yml:
default_src:
- "'self'"
script_src:
- "'self'"
style_src:
- "'self'"
img_src:
- "'self'"
- "data:"
connect_src:
- "'self'"
object_src:
- "'none'"
base_uri:
- "'self'"
form_action:
- "'self'"
frame_ancestors:
- "'none'"
Then generate a meta tag for local testing:
<meta http-equiv="Content-Security-Policy"
content="default-src {% for v in site.data.csp.default_src %}{{ v }} {% endfor %}; script-src {% for v in site.data.csp.script_src %}{{ v }} {% endfor %}; style-src {% for v in site.data.csp.style_src %}{{ v }} {% endfor %}; img-src {% for v in site.data.csp.img_src %}{{ v }} {% endfor %}; connect-src {% for v in site.data.csp.connect_src %}{{ v }} {% endfor %}; object-src {% for v in site.data.csp.object_src %}{{ v }} {% endfor %}; base-uri {% for v in site.data.csp.base_uri %}{{ v }} {% endfor %}; form-action {% for v in site.data.csp.form_action %}{{ v }} {% endfor %}; frame-ancestors {% for v in site.data.csp.frame_ancestors %}{{ v }} {% endfor %}">
I would not rely on the meta tag for final production deployment if proper headers are available, but it’s handy during development.
Official CSP documentation is here: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
Use Report-Only first
If you can, deploy with Content-Security-Policy-Report-Only before enforcing.
Example:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
That lets you see what would break without actually breaking it.
For static sites, this is especially useful because stray inline snippets tend to hide in templates, includes, and old theme fragments.
My recommended rollout plan
For a Jekyll site, I’d do it in this order:
- Add a minimal strict policy in Report-Only
- Remove inline scripts by moving them to files
- Remove inline styles where possible
- Add exact third-party domains one by one
- Enforce the header
- Revisit regularly when new tools are added
If you’re choosing between “perfect CSP later” and “good CSP now,” ship the good CSP now. Just don’t ship fake security like script-src 'self' 'unsafe-inline' https: * and call it hardened.
Static sites are one of the few places where CSP can stay understandable. Take advantage of that.