eagle-head
Proposal: mix phx.gen.csp — CSP Level 3 support for Phoenix
Hi everyone,
I’ve been researching Content Security Policy Level 3 support in Phoenix and wanted to share my findings and a proposal for discussion. I’ve read through several forum threads on the topic (nonce as boolean, passing nonce to LiveView, CSP breaking error pages, dynamic styles with CSP) and Dan Schultzer’s excellent blog post on CSP with LiveView. It’s clear the community has been working around this manually for a while.
The current state
Phoenix 1.8 took a great first step — put_secure_browser_headers now sets base-uri 'self'; frame-ancestors 'self'; by default (thanks to chrismccord and SteffenDE). But there’s still no built-in path to a strict CSP with nonces and strict-dynamic.
Developers who want this today need to:
- Write a custom Plug for nonce generation
- Figure out how to propagate the nonce to LiveView
- Deal with the inline
<script>in the defaultroot.html.heex(dark mode toggle) - Handle CSP breaking the Plug.Debugger error page in dev
- Navigate browser quirks (Chrome hides nonce values in the DOM inspector, which is intentional but confusing)
A possible direction: mix phx.gen.csp
I’m exploring the idea of a generator — similar to phx.gen.auth — that would set up CSP Level 3 support. The generator would:
- Create a CSP nonce Plug in the user’s project
- Modify
router.ex,root.html.heex, andapp.js - Add a
<meta name="csp-nonce">tag for JS access
The code would live entirely in the user’s project (not in Phoenix core), so it’s fully customizable.
Two tiers of support (proposed)
Tier 1 — Complete (LiveView ± DaisyUI): Everything works out of the box after running the generator.
Tier 2 — Base + docs (API, SPAs, external JS libs): The generator sets up the foundation. Documentation provides guidance for adapting to specific stacks.
Default policy (proposed, in report-only mode)
default-src 'self';
script-src 'nonce-{nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self' ws: wss:;
object-src 'none';
form-action 'self';
base-uri 'self';
frame-ancestors 'self'
CSP violation reporting (proposed)
A pluggable reporting architecture using a behaviour — the controller receives reports and delegates to a configurable handler. Users could plug in Logger, Ecto, Grafana/Loki, Datadog, Sentry, or any custom backend. This would be documented as boilerplate, not auto-generated.
Open questions — I’d love your input
These are the design decisions I’m not sure about. I have opinions but I’d rather hear from the community first:
1. Nonce propagation to LiveView
The community has converged on put_session + on_mount (as described in Dan Schultzer’s post). Another option is connect_params via the LiveSocket JS constructor. Each has trade-offs:
put_session+on_mount: Works reliably, but adds data to the session cookieconnect_params: Doesn’t touch the session, but requires JS-side changes toapp.js
Which approach do you prefer? Is there a third option I’m missing?
2. Development mode
CSP strict breaks Plug.Debugger’s error page (reported here). Options:
- Disable CSP in dev — simplest, but you lose dev/prod parity
- Relaxed CSP in dev — add
'unsafe-inline'toscript-srconly in dev - Fix Plug.Debugger — add nonce support to the debugger itself (separate PR to Plug)
What would you expect as default behavior?
3. Generator vs standalone library
Should this be:
- Part of Phoenix (
mix phx.gen.csp) — discoverable, maintained with the framework - Standalone hex package (e.g.,
phx_csp) — independent release cycle, less pressure on the core team - Just documentation — add a comprehensive guide to
guides/security.mdand let developers copy/paste
4. Default enforcement mode
Should the generated policy start as:
report-only— safe rollout, but users might forget to switch to enforcement- Enforcing — secure by default, but might break things if the user has inline scripts from third-party libs
5. The inline dark mode script
The default root.html.heex has an inline <script> for dark mode. With nonce-based CSP, options are:
- Keep inline + add nonce — zero FOUC, CSP compliant
- Move to external file — simpler CSP but risks FOUC
- Leave as-is — the generator adds the nonce attribute
Is there a preference?
6. Interaction with put_secure_browser_headers
The existing put_secure_browser_headers sets a basic CSP. The nonce plug would need to either replace or extend it. When both headers coexist (content-security-policy + content-security-policy-report-only), browsers apply both. When migrating to enforcement, the nonce plug would replace the default CSP.
Is this interaction clear enough, or should the generator modify put_secure_browser_headers directly?
What I have so far
I’ve written a detailed design spec covering architecture, data flow, error handling, testing, and migration for existing projects. I’m happy to share it if there’s interest.
I also have a JS library compatibility analysis:
| Library | Works? | Notes |
|---|---|---|
| LiveView | Yes | Full Tier 1 support |
| DaisyUI | Yes | CSS only, no JS issues |
| Stimulus | Yes | No inline scripts |
| Alpine.js | Partial | Needs 'unsafe-eval' |
| Flowbite (inline handlers) | Partial | Needs 'unsafe-hashes' |
| React / Vue / Svelte (SPA) | Yes | Modern frameworks avoid eval |
| Google Analytics / GTM | Yes | 'strict-dynamic' propagates trust |
Next steps
Depending on the feedback here, I’d be happy to:
- Share the full design spec for review
- Submit a PR (to Phoenix or as a standalone package)
- Start with just the documentation/guide if that’s preferred
Looking forward to hearing your thoughts — especially on the open questions above. Any experience with CSP in production Phoenix apps would be incredibly valuable!
Most Liked
eagle-head
Update: CSP Level 3 — findings from a real implementation
Reproduction repo: eagle-head/drink_water
mainbranch — CSP Level 3 with'unsafe-inline'instyle-src(working)feat/csp-level3-strictbranch — strict CSP (no'unsafe-inline') with violation reporting enabled. Runmix phx.serverand open/dashboardto see violations inlogs/csp_violations.log.
Following the feedback from @steffend and @LostKobrakai, I implemented CSP Level 3 (enforcing, nonce + strict-dynamic) on this project and collected CSP violation reports. Here’s what I found.
What works with CSP Level 3
script-srcwith nonce +strict-dynamic— works perfectly. All scripts execute correctly with nonce attributes.strict-dynamicpropagates trust as expected.- Nonce propagation to LiveView — works via
:sessionoption onlive_session(thanks @LostKobrakai). <meta name="csp-nonce">tag — JavaScript can read the nonce for client-side use.- CSP violation reporting — implemented a
securitypolicyviolationevent listener that sends reports to a Phoenix endpoint, sincereport-to/report-uridon’t work on localhost (Chrome requires HTTPS and a real domain for the Reporting API).
What breaks: style-src without 'unsafe-inline'
When I set style-src 'self' 'nonce-...' (no 'unsafe-inline'), I got 30+ violations per page load on /dashboard. All violations are style-src-attr (inline style attributes). Three distinct sources were identified.
Before diving in, here’s the key insight from the MDN style-src documentation:
“Styles properties that are set directly on the element’s
styleproperty will not be blocked, allowing users to safely manipulate styles via JavaScript.”
This means the CSP spec distinguishes between:
| Method | Blocked by style-src? |
Reference |
|---|---|---|
element.style.property = "value" |
No — always allowed | MDN: style-src |
element.style.setProperty("prop", "val") |
No — always allowed | Same DOM API as above |
element.setAttribute("style", "...") |
Yes — blocked | MDN: style-src |
element.style.cssText = "..." |
Yes — blocked | MDN: style-src |
<style> tag without nonce |
Yes — blocked | W3C CSP3 spec |
style="..." attribute in HTML |
Yes — blocked | W3C CSP3 spec |
Important: a nonce on a <script> tag does not grant that script permission to use setAttribute("style") or cssText. These are controlled by style-src, not script-src. The only CSP-safe way for JavaScript to modify styles is via the DOM style API (element.style.property or element.style.setProperty()). See MDN: style-src and W3C CSP3 §8.3.
1. morphdom in Phoenix LiveView (critical blocker)
LiveView’s morphdom uses setAttribute("style", ...) in the morphAttrs function during DOM patching:
// morphdom's morphAttrs function
fromNode.setAttribute(attrName, attrValue); // when attrName === "style" → CSP violation
setAttribute("style", ...) is blocked by CSP style-src without 'unsafe-inline' (MDN reference). This is inherent to how morphdom works — every server-sent patch that changes a style attribute triggers a violation. This means LiveView itself is incompatible with strict style-src.
The fix would be to have morphdom treat style as a special case. Instead of setAttribute("style", value), parse the style string and apply property-by-property via the DOM API:
// CSP-safe approach — parse and apply per-property
function applyStyleCSPSafe(el, styleString) {
// Remove properties no longer present
while (el.style.length > 0) {
el.style.removeProperty(el.style[0]);
}
// Parse and apply new properties
const temp = document.createElement("div");
temp.style.cssText = styleString; // off-DOM, no CSP violation
for (const prop of temp.style) {
el.style.setProperty(
prop,
temp.style.getPropertyValue(prop),
temp.style.getPropertyPriority(prop),
);
}
}
Note: my original proposal suggested el.style.cssText = value as a fix, but MDN confirms that cssText is also blocked by CSP. The only safe path is element.style.setProperty() or direct property assignment.
2. DaisyUI components (countdown, radial-progress)
DaisyUI uses style="--value:X" as its data API for countdown and radial-progress components (DaisyUI radial-progress docs, DaisyUI countdown docs). No alternative (data-* attributes, CSS classes) is provided. This is a DaisyUI issue, not Phoenix.
I investigated the DaisyUI source code — only these 2 components out of 50+ require user-provided style= attributes. All other components use CSS classes to set custom properties internally.
3. topbar.js
Uses .style.property = value (direct property assignment). Per the MDN documentation, this should NOT be blocked by CSP. The violations reported from topbar are likely caused by morphdom re-applying the style attribute after DOM patches, not by topbar itself.
Summary
| Layer | Issue | Who needs to fix it | Reference |
|---|---|---|---|
| morphdom (LiveView) | setAttribute("style") blocked by CSP |
phoenix_live_view — patch morphdom to apply styles per-property via el.style.setProperty() |
MDN: style-src |
| DaisyUI | style="--value:X" as component API |
daisyui — library-wide CSP support | DaisyUI source |
| topbar | Likely false positive from morphdom | Verify after morphdom fix | MDN: style-src |
What this means for the CSP documentation
As @steffend suggested, the default should be enforcing. Based on these findings, the documentation should be honest:
script-src: CSP Level 3 works perfectly with nonce +strict-dynamic. No caveats.style-src:'unsafe-inline'is currently required when using LiveView, due to morphdom’s use ofsetAttribute("style")(MDN confirms this is blocked). This is a known limitation, not a design choice.
I’d like to contribute fixes
I’m willing to:
- Open an issue + PR on
phoenix_live_view— patch morphdom’smorphAttrsto apply styles per-property viael.style.setProperty()instead ofsetAttribute("style"), which is the only CSP-safe approach per the W3C CSP3 spec - Open an issue + PR on DaisyUI — not a workaround for specific components, but a robust, library-wide solution that enables CSP compliance for any existing or future component that relies on inline styles or JavaScript. The goal is to make CSP Level 3 a first-class concern in DaisyUI’s architecture.
- Write the Phoenix CSP guide — documenting the current state, the workaround (
'unsafe-inline'for style-src), and how to achieve full CSP Level 3 once the morphdom fix lands
Before opening the issues/PRs, I wanted to check with the community:
- Does the morphdom approach (
el.style.setProperty()per-property instead ofsetAttribute("style")) seem right? Are there edge cases I’m missing? - Is there a reason morphdom uses
setAttributefor style instead of the DOM style API? - Has anyone else hit this wall with CSP + LiveView?
References
- MDN: Content-Security-Policy style-src — definitive reference on what is and isn’t blocked
- W3C CSP Level 3 Specification — formal spec
- W3C CSP3 §8.3 — Should element’s inline type behavior be blocked? — spec section on inline style blocking
- MDN: style-src-attr — controls inline
styleattributes specifically - MDN: style-src-elem — controls
<style>elements and stylesheets - MDN: Reporting API — why
report-todoesn’t work on localhost - MDN: securitypolicyviolation event — client-side violation capture
- morphdom source (GitHub) — the DOM diffing library used by LiveView
- DaisyUI radial-progress source — uses
var(--value)set viastyle=attribute - DaisyUI countdown source — same pattern
- Dan Schultzer’s blog post on CSP with LiveView — community reference on CSP + LiveView
LostKobrakai
You can put data on the LV session (the one inlined in the markup) with the :session option on the live_session macro. That won’t put the data on the session cookie.
steffend
If we make it part of Phoenix, I think it should be part of the documentation. Phoenix generators mostly generate new files or assume a fresh project without major modifications. Adding CSP is useful for any project, so a generator that bails out as soon as something isn’t configured as the default phx.new project is probably not worth the effort? Of course, an igniter installer that does the default setup would be nice too, but that’s probably best explored in a separate package first.
I’m not an expert on CSP, but I feel like the default should be enforcing and also used in development. So if we need any changes, like for Plug.Debugger, we should implement those. The docs can then also contain a section of report only mode and how one could implement that.
Popular in Proposals: Ideas
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









