CSP the hard way: what rolling it out at Workera taught me
When I joined Workera I had six years of frontend security behind me at EclecticIQ, and for at least two of them I had been the go-to person for Content Security Policy. So when CSP landed on our plate, it looked like my kind of job. We estimated a month: it took at least four (not full time, since it ran alongside other work, but four months all the same).
This post is the story of those months, from the first broken page to a policy we could enforce, plus what I’d do differently today.
Table of contents
- Where it started
- Removing the inline code
- Where it breaks
- Asking the vendor instead of patching
- Teaching the team the vocabulary
- How I’d introduce CSP on a new project today
Where it started
On paper CSP is simple: the server sends a header listing where scripts, styles, images and connections may come from, and the browser blocks everything else. If an attacker manages to inject a script, the browser refuses to run it.
The push came from outside: an important customer wanted CSP, gave us a deadline, and one requirement was no unsafe-inline, for scripts or for styles. unsafe-inline is the escape hatch that lets inline code run, and it undoes most of what the policy protects against. So the order of work was: first the components, then the templates, then the third-party software.
Removing the inline code
Dropping unsafe-inline caused the most work. On the style side, the inline styles had to go. On the script side, we had a lot of inline JavaScript, much of it in templates that used Alpine.js.
Problem: Alpine ships a CSP-compatible build, but we hadn’t been using it. So the inline code had to be found and moved out, template by template.
Where it breaks
A policy that works on one page can break another. Pages load different components and different libraries, so you can click through the app, see no errors, and still miss the one page that pulls in something the policy doesn’t allow. Exercising every page by hand in a real product is close to impossible.
Our first production rollout proved it: one of the assessment question types (the assessment is Workera’s core product, the one that measures people’s skills) depended on a library the policy blocked. So we went back and fixed it before enforcing again.
Follow-up problem: the third-party services, many of which don’t support CSP at all. We had problems with PostHog, with a service that shows announcement banners inside the web app, with tldraw and with ElevenLabs. Plenty of vendors don’t mention CSP anywhere in their docs. When their code needs something your policy forbids, you have three options: loosen the policy for everyone, patch their code, or drop the vendor.
Asking the vendor instead of patching
The most instructive case was tldraw. I tested it against our policy and realized that making it compliant would be easy. I could have patched it myself, but in my opinion a local patch is a liability: every upgrade can undo it, or bring in a new violation that you only discover in production.
So I contacted the tldraw team, and they were open to adding support (the ticket is public). A fix upstream helps everyone who runs the library under a strict policy, and it survives the next release.
Teaching the team the vocabulary
Part of the job had nothing to do with code. Before this project, CSP wasn’t something the engineering team talked about, and terms like directives, nonces and hashes were new to most people.
So a lot of my time went into making that vocabulary common, and into writing compliance guidelines: what a new component may and may not do, and what to check before adding a vendor.
How I’d introduce CSP on a new project today
- Start in report-only mode.
Content-Security-Policy-Report-Onlymakes the browser report violations without blocking anything. It would have caught our assessment problem (see “Where it breaks”) before enforcement. - Collect the reports and fix what they show. Real traffic exercises the pages you would never think to test.
- Inventory the third parties up front. Check whether each vendor works under a strict policy before you depend on it, and talk to the ones that don’t.
- Enforce only when the reports go quiet, and deploy when someone is around to watch.
- Version the policy like code. It changes every time you add a vendor or a feature, and you want to know what changed and why.
Point 5 is the reason I started cspgen, an open-source CLI for reviewing, versioning and rolling out a Content Security Policy.
Takeaways
Whatever your estimate for CSP compliance is, it’s probably low (ours was a month, and it took four!). Also, ask vendors about CSP before you integrate them, and when a library needs a fix, ask upstream before patching it locally.
Suggestion: never enforce a policy you haven’t run in report-only mode against real traffic!
Thanks for reading!