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:

  1. Write a custom Plug for nonce generation
  2. Figure out how to propagate the nonce to LiveView
  3. Deal with the inline <script> in the default root.html.heex (dark mode toggle)
  4. Handle CSP breaking the Plug.Debugger error page in dev
  5. 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:

  1. Create a CSP nonce Plug in the user’s project
  2. Modify router.ex, root.html.heex, and app.js
  3. 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 cookie
  • connect_params: Doesn’t touch the session, but requires JS-side changes to app.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' to script-src only 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.md and 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!

11 Likes

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.

2 Likes

Thanks @LostKobrakai! That’s a great point — using :session on live_session is cleaner than both put_session (avoids cookie bloat) and connect_params (no JS changes needed). I hadn’t considered it and it seems like the best of both worlds for nonce propagation.

I’ll update the design spec to include this as the recommended approach. Appreciate the input!

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.

2 Likes

Thanks @steffend, this is really helpful direction from the core team!

I agree with your points:

Documentation over generator — Makes total sense. A generator that bails on customized projects defeats the purpose, especially since CSP is most needed by existing projects. A comprehensive section in guides/security.md with clear examples for LiveView, controllers, and API scenarios would serve the community much better.

Enforcing by default — You’re right. Security should be opt-out, not opt-in. If the docs guide people toward enforcing from the start, that’s a stronger default posture. Report-only can be documented as an optional migration strategy for teams adopting CSP on existing production apps.

Fix Plug.Debugger — Agreed. If CSP strict breaks the error page, the right fix is in Plug.Debugger, not relaxing CSP in dev. I’d be happy to look into a separate PR for that.

So the plan would be:

  1. PR to Phoenix — CSP section in guides/security.md covering nonce setup, LiveView integration (using :session on live_session per @LostKobrakai’s suggestion), enforcement, library compatibility, and violation reporting
  2. PR to Plug — Fix Plug.Debugger to work with strict CSP (nonce support or similar)

I’ll start with the documentation PR. Thanks again for the guidance!

2 Likes

Update: CSP Level 3 — findings from a real implementation

Reproduction repo: eagle-head/drink_water

  • main branch — CSP Level 3 with 'unsafe-inline' in style-src (working)
  • feat/csp-level3-strict branch — strict CSP (no 'unsafe-inline') with violation reporting enabled. Run mix phx.server and open /dashboard to see violations in logs/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-src with nonce + strict-dynamic — works perfectly. All scripts execute correctly with nonce attributes. strict-dynamic propagates trust as expected.
  • Nonce propagation to LiveView — works via :session option on live_session (thanks @LostKobrakai).
  • <meta name="csp-nonce"> tag — JavaScript can read the nonce for client-side use.
  • CSP violation reporting — implemented a securitypolicyviolation event listener that sends reports to a Phoenix endpoint, since report-to / report-uri don’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 style property 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 of setAttribute("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:

  1. Open an issue + PR on phoenix_live_view — patch morphdom’s morphAttrs to apply styles per-property via el.style.setProperty() instead of setAttribute("style"), which is the only CSP-safe approach per the W3C CSP3 spec
  2. 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.
  3. 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 of setAttribute("style")) seem right? Are there edge cases I’m missing?
  • Is there a reason morphdom uses setAttribute for style instead of the DOM style API?
  • Has anyone else hit this wall with CSP + LiveView?

References

5 Likes

Update: opened a DaisyUI issue for CSP compliance

Following the findings shared in my previous post, I opened an issue on the DaisyUI repository:

saadeghi/daisyui#4475 — countdown and radial-progress break Content Security Policy

The issue proposes a data-value attribute with a small JS bridge as a CSP-safe alternative to the current style="--value:X" API. The existing API would remain for backward compatibility.

A pure-CSS solution via attr(data-value type(<number>)) (CSS Values Level 5) would be ideal, but browser support is Chrome-only today (133+). Firefox has it in Interop 2026, Safari has no known timeline.

Only 2 of 58 DaisyUI components are affected — the rest are already CSP-compatible.

LiveView’s morphdom uses setAttribute("style", ...) in the morphAttrs function during DOM patching:

morphdom patches any attribute. So this is only a problem if you have inline styles in your LiveView template. The workaround is interesting though, so a PR to morphdom that explores it sounds good to me.

2 Likes

@steffend Following up on your feedback — I’ve submitted a PR to morphdom upstream:

The fix adds a syncStyle() function to morphAttrs.js that replaces setAttribute("style", ...) with the DOM style API (style.setProperty / style.removeProperty). Per the W3C CSP3 spec, the DOM style API is explicitly exempt from CSP restrictions — so this eliminates the style-src violation without any workaround.

How it works:

  1. Compares fromNode.style.cssText vs toNode.style.cssText as a fast-path (skip if unchanged)

  2. Removes properties no longer in the target via removeProperty()

  3. Copies properties from the target via setProperty(name, value, priority), only when the value or priority actually differs

Handles standard properties, shorthands, CSS custom variables (--value, --size), and !important priority. Non-style attributes are unchanged — still use setAttribute as before.

This would unblock strict style-src for LiveView once morphdom releases a new version with this fix.

2 Likes

@steffend and @LostKobrakai I’d like to broaden the CSP discussion to something I think is related and worth reflecting on: a roadmap toward making Phoenix CSS-agnostic.

I’m not suggesting we rip out Tailwind or DaisyUI overnight — they serve their purpose well for quick starts. But I believe the project should gradually move toward being framework-agnostic on the CSS/component side. Here’s why:

The security argument

Once we implement CSP support in Phoenix (which this thread is about), the default template must be CSP-compatible out of the box. Today, DaisyUI has 2 components that require 'unsafe-inline' in style-src (daisyui#4475), and the initial response from their core contributors has been dismissive — a thumbs-down with no comment. We can’t guarantee that a third-party CSS library will align with Phoenix’s security goals.

The ecosystem has changed

When DaisyUI was adopted (March 2025), it was one of the few CSS-only component libraries that checked all the boxes José outlined in #6121. But the landscape has evolved rapidly:

  • Framework-agnostic component libraries are now abundant — Petal Components (1M+ Hex downloads), Mishka Chelekom (80+ components, generator-based), SaladUI (shadcn/ui-inspired), Noora, Doggo (headless/accessible), and more
  • Building UI is dramatically easier than before — AI tools, copy-paste component registries (shadcn model), and mature headless libraries mean developers no longer need a bundled CSS framework to be productive
  • Phoenix does server-side rendering — we send ready HTML to the client. Any CSS library that produces valid HTML+CSS works. We only ship the minimal JS each library needs for interactivity. This makes us naturally positioned to be CSS-agnostic

Evidence from the community

The data suggests the community is ready for this:

  • Forum thread on --no-daisy flag: 67 posts, 123 likes — the most engaged CSS discussion in the forum
  • Poll data: 35% of respondents want Tailwind without DaisyUI; 14% want neither
  • Issue #6626: José himself noted that DaisyUI creates a “cognitive lock-in” — AI agents only use DaisyUI when it’s available, even when plain Tailwind would be better. If this happens to AI, it likely happens to beginners too
  • Issue #6204: DaisyUI deprecated fieldset-label, breaking Phoenix’s CoreComponents — a maintenance cost we absorb from a dependency we don’t control
  • Issue #6433: The dark variant with system mode is still broken and open

What I’m proposing (not a revolution, an evolution)

A gradual roadmap, not a breaking change:

  1. Short term: Ensure the default template is CSP-compatible (the work in this thread). Document how to use Phoenix with different CSS libraries
  2. Medium term: Make CoreComponents a truly headless/semantic layer that any CSS library can style. Provide documentation and examples for popular libraries (DaisyUI, Petal, Mishka, plain Tailwind, etc.)
  3. Long term: Consider a pluggable CSS system in phx.new — similar to what @zachdaniel is exploring with Igniter, or the wizard-style installers he mentioned in #6121

DaisyUI can absolutely remain the default for quick starts. But the architecture should make it a choice, not a coupling. This aligns with Phoenix’s philosophy of being explicit and transparent — the same principles that make the framework great.

What do you think? Is this something worth exploring as part of the broader CSP effort, or should it be a separate discussion?

1 Like

I’m not sure that I necessarily agree with this. Strict CSP has benefits for sure, but it’s still an advanced topic that one might not want to enforce in a beginner friendly, out of the box, experience (phx.new). So assuming this proposal ends up as a comprehensive guide about CSP in the docs, there’s no direct link to phx.new adhering to that.

I would not mix daisyUI into this discussion. In fact, I’d argue that Phoenix is CSS-agnostic. The installer is a starting point for new applications that we maintain. Phoenix - the framework - does not make any assumptions about what kind of HTML your template ends up rendering and what CSS or JS libraries are involved in that. You can use vite instead of esbuild, you can use any of the libraries you mentioned instead of daisy.

The big problem with any change to the generators is that it necessarily increases the maintenance burden on the very small Phoenix team. Providing unstyled components either means having two variants to maintain, or defining a strict API for the generators to ensure that third party libraries don’t break when we need to do changes between Phoenix releases, which limits us in what we can change. (That problem already exists today, but by not defining that API we’re not restricting what we can do, putting the burden on the libraries to keep up - which of course is not optimal.).

If you can come up with a maintainable pluggable CSS system, I’d be happy to hear about it, but I currently don’t see a way towards that that does not require a very significant amount of work.

So tl;dr: let’s keep the CSS discussion separate from CSP.

1 Like

You’re absolutely right — I shouldn’t have mixed the two topics. Phoenix is already CSS-agnostic at the framework level, and the maintenance burden on the core team is a real constraint I hadn’t fully appreciated.

Let’s keep the focus on CSP. The morphdom PR (patrick-steele-idem/morphdom#288) is up — once that moves forward, we’ll have a clearer picture for the CSP guide.

It might be worth taking a look at this PR to phx_install where we look at the feasibility of replacing esbuild et al with bun. I’m not planning on merging that PR because it breaks my current goal of “100% compatible with phx.new” (although it’s not 100% compatible at the moment because it doesn’t include daisy yet) but I will reuse the ways that it splits up the generators so that we can build igniters on top of them that do this.

2 Likes

Thanks for the pointer! The composable subtask architecture in that PR is really well thought out — the dynamic dispatch pattern (phx.install.assets.tailwind.#{bundler} ) makes it straightforward to add new bundlers without touching existing files. I’ll keep an eye on how the generator split evolves, especially since @steffend mentioned exploring Igniter installers as separate packages.