Milligram is tiny, clean, and boring in the best possible way. That makes it a great fit for a strict Content Security Policy. If your CSS framework is just a stylesheet and not a JavaScript carnival, you can usually lock things down harder than most teams think.
I’ve seen the opposite happen in production: a simple site starts with Milligram, then marketing adds Google Tag Manager, analytics, a consent banner, maybe a form embed, and suddenly the CSP turns into a landfill of wildcard domains and unsafe-inline.
Here’s a real-world case study based on a setup similar to what you’d find on a small content site.
The app
A developer site built with:
- Milligram for styling
- A mostly static frontend
- Google Tag Manager
- Google Analytics
- Cookiebot for consent
- A small backend API
- No SPA framework
- No need for inline JavaScript beyond bootstrapping tags
That last point matters. Milligram itself does not require JavaScript. So if your CSP is weak, it’s almost never Milligram’s fault.
The “before” CSP
This is based on a real CSP header pattern seen in the wild:
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-MzNhMGQwMTctMDcyOS00N2UxLWJjYjQtODRjYjI5OTJjZDQ5' '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';
At first glance, this isn’t terrible. There are good choices here:
object-src 'none'frame-ancestors 'none'base-uri 'self'form-action 'self'- nonce-based scripts
strict-dynamic
But there are also some pretty common compromises.
What’s weak here
1. style-src 'unsafe-inline'
This is the biggest smell.
Milligram is just CSS. You can serve milligram.min.css from your own origin and avoid inline styles entirely in most cases. Keeping unsafe-inline around usually means one of these happened:
- the app has inline
<style>blocks in templates - a consent or analytics tool injects styles
- nobody wanted to untangle it
For a framework this small, I’d push hard to remove it.
2. default-src is doing too much
A broad default-src that includes analytics and consent domains is lazy policy design. It works, but it makes the policy harder to reason about.
I prefer a narrow default-src 'self' and then explicit directives for each resource type.
3. img-src https:
This is practical, but broad. If you know your image hosts, list them. https: is easy, but it gives more room than necessary.
4. Source lists mixed with strict-dynamic
If you use nonce-based scripts with strict-dynamic, modern browsers will trust nonce-authorized scripts and the scripts they load. That’s good. But many teams keep long host allowlists anyway because they want compatibility with older browsers.
That’s not wrong, but you should know why it’s there. Otherwise the policy looks stricter than it really is.
For directive behavior details, the official docs and https://csp-guide.com are useful references.
The frontend before cleanup
Here’s the kind of HTML I usually find before a CSP cleanup:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Milligram Demo</title>
<link rel="stylesheet" href="/css/milligram.min.css">
<style>
.hero { margin-top: 4rem; }
.hidden { display: none; }
</style>
</head>
<body>
<main class="container hero">
<h1>Ship faster</h1>
<p>Minimal CSS, simple content.</p>
<button style="background:#9b4dca;color:white">Start</button>
</main>
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'page_view' });
</script>
<script nonce="{{ .CSPNonce }}" src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
</body>
</html>
This is exactly how teams end up needing both a nonce and unsafe-inline:
- inline
<style> - inline
style="" - inline
<script>
Milligram didn’t cause any of that. The templates did.
The cleanup
The fix was boring, which is usually a good sign.
Step 1: move all styles into static CSS
/* /css/app.css */
.hero {
margin-top: 4rem;
}
.hidden {
display: none;
}
.button-primary {
background: #9b4dca;
color: white;
}
Then update the HTML:
<link rel="stylesheet" href="/css/milligram.min.css">
<link rel="stylesheet" href="/css/app.css">
And replace inline style attributes:
<button class="button-primary">Start</button>
Step 2: nonce the inline bootstrap script
If you need a tiny inline script for GTM or app bootstrapping, nonce it properly.
<script nonce="{{ .CSPNonce }}">
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'page_view' });
</script>
<script nonce="{{ .CSPNonce }}" src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
Step 3: generate a fresh nonce per response
Example in Node/Express:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use((req, res, next) => {
const nonce = crypto.randomUUID();
res.locals.cspNonce = nonce;
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
"style-src 'self' https://*.cookiebot.com https://consent.cookiebot.com",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.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'"
].join("; ")
);
next();
});
Then in your template:
<script nonce="{{cspNonce}}">
window.dataLayer = window.dataLayer || [];
</script>
The “after” CSP
After cleanup, the policy got smaller and stricter:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_PER_RESPONSE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.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';
The key change is simple:
style-src 'unsafe-inline'is gone
That’s the win.
Why this works well for Milligram
Milligram doesn’t force weird CSP exceptions. That’s one reason I like it for content sites and internal tools.
A practical Milligram setup usually needs only:
default-src 'self';
style-src 'self';
font-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
That’s a very healthy baseline.
Then you add only what your app actually uses:
- analytics endpoints in
connect-src - GTM in
script-src - consent iframe domains in
frame-src
That’s the right mental model. Start with the CSS framework’s actual behavior, not with a copy-pasted header from a JavaScript-heavy app.
A few gotchas I hit
Cookie consent tools often drag style-src backward
This is the most annoying part of these deployments. Your app may be clean, but the consent product injects styles or expects looser style rules.
When that happens, I try this order:
- self-host all app CSS
- remove all inline styles in templates
- test consent flows
- only if absolutely necessary, scope the looseness to the tool’s sources
I still avoid unsafe-inline unless there’s no realistic path around it.
strict-dynamic is great, but know your browser support story
If you support older browsers, host allowlists still matter. If your audience is mostly modern browsers, nonce + strict-dynamic is a strong setup.
Just don’t cargo-cult it. If your team can’t explain why both are present, clean it up or document it.
Report-only first saves pain
Before enforcing a new policy, ship it in report-only mode and watch what breaks.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_PER_RESPONSE}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.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';
That catches the weird stuff fast.
My recommended baseline for Milligram
If you’re running a plain Milligram site with no tag manager nonsense, I’d start here:
Content-Security-Policy:
default-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
Then add exceptions one by one, based on actual violations.
That’s the real lesson from this case study: Milligram makes CSP easier, not harder. If your policy is sprawling, look at your third-party scripts and your templates before blaming the CSS framework.
For official directive definitions, check the CSP documentation from browser vendors and the CSP reference at https://csp-guide.com.