Skip to content

Add opt-in CSP nonce support for scripts - #20301

Open
m-develops wants to merge 7 commits into
filamentphp:5.xfrom
m-develops:feat/csp-nonce
Open

m-develops wants to merge 7 commits into
filamentphp:5.xfrom
m-develops:feat/csp-nonce

Conversation

@m-develops

@m-develops m-develops commented Jul 29, 2026

Copy link
Copy Markdown

Description

Filament currently requires script-src 'unsafe-inline', which defeats much of the point of having a Content Security Policy. This adds opt-in nonce support for the scripts Filament renders, as discussed in #7032, and resolves #8329.

Registered from a service provider:

use Filament\Support\Facades\FilamentCsp;

FilamentCsp::useNonce(static fn (): string => csp_nonce());

Every script rendered by the asset manager, and the inline window.filamentData script, then carry the nonce. The closure is resolved once per request, so all elements in a response share the same value. A closure re-invoked per element would produce a nonce the browser rejects on every tag but the first.

Two helpers are available for application and plugin templates:

  • \Filament\Support\csp_nonce() returns the raw value, or null
  • \Filament\Support\csp_nonce_html() returns the whole nonce="…" attribute, or an empty string

Why FilamentCsp rather than FilamentAsset

Following up on your question in #7032, CSP turns out not to be purely asset related. Of the inline <script> tags this work needs to cover, only one is in the asset manager's own view; the rest are in ordinary Blade templates (base.blade.php, sidebar.blade.php, page/index.blade.php, the notifications views). It also cannot go on the panels Filament facade, because assets render without a panel: the scaffolding stub layout uses @filamentScripts in a plain Laravel layout.

Happy to move it if you would prefer it elsewhere.

Scope

This PR covers the nonce API and the asset manager's output only.

Styles are not touched, and are not planned. Per your review on #19230, and because Livewire, Alpine and TipTap all rely on inline styles, style-src 'unsafe-inline' remains required.

Planned follow-ups

Keeping this PR small does mean a panel is not fully CSP compliant the moment it lands, so for context, here is the remaining work in the order I would submit it:

  1. Nonces on the inline <script> tags in Filament's own Blade templates. 12 tags across 6 files. The dark-mode script in base.blade.php is the only violation left on a standard panel page after this PR, and that step removes it.
  2. Alpine expressions the CSP-safe evaluator cannot parse, so 'unsafe-eval' can be dropped too. Around 29 expressions plus 5 x-html usages, concentrated in support, tables and panels, with a lot of duplication between them.
  3. Nonce behaviour across livewire/update responses. A nonce is per response, but Livewire morphs later responses into a document served under an earlier one. This may belong in Livewire rather than Filament, so I would establish that before opening anything.

Each would be a separate PR, opened when it is complete rather than as a WIP.

Known limitations, documented

Filament cannot add nonces to content it does not render: render hooks, the scripts Blade stack, RawJs, and scripts passed to Js::html() as raw <script> markup. The docs explain that authors add the nonce themselves in those cases.

The docs also note that the CSP middleware must be registered in the panel's middleware() rather than Laravel's web group. Panels build their own stack and never resolve web, so a policy registered on web silently never applies to panel routes.

Visual changes

None. Until a nonce is configured, the rendered output is unchanged byte for byte, verified by capturing renderScripts() and renderStyles() before and after the change and diffing them.

Functional changes

  • Code style has been fixed by running the composer cs command.
  • Changes have been tested to not break existing functionality.
  • Documentation is up-to-date.

Tested with the full suite (7154 passing, including browser tests) and against a real v5 application with third-party panel plugins, served under script-src 'self' 'unsafe-eval' blob: 'nonce-…' with no 'unsafe-inline'. Every script Filament rendered carried the nonce, and a single distinct nonce appeared throughout the page, matching the header. 'unsafe-eval' was kept because Livewire and Alpine still need it until csp_safe is enabled, which is separate work.

Adds a `FilamentCsp` manager and facade in the support package, so that
applications sending a Content Security Policy can have Filament render a
`nonce` attribute on the scripts it outputs, removing the need for
`'unsafe-inline'` in `script-src`.

    FilamentCsp::useNonce(static fn (): string => csp_nonce());

The nonce is resolved once per request, so every element in a response
receives the same value. `\Filament\Support\csp_nonce()` returns the raw
value and `\Filament\Support\csp_nonce_html()` returns the whole attribute,
for use in application and plugin templates.

The manager lives in the support package rather than on the panels
`Filament` facade because assets render without a panel, and CSP is not
purely asset related.

Styles are deliberately untouched: Livewire, Alpine and TipTap all rely on
inline styles, so `style-src 'unsafe-inline'` is still required.

Until a nonce is configured the rendered output is unchanged, byte for byte.
@danharrin danharrin added the enhancement New feature or request label Jul 29, 2026
@danharrin danharrin added this to the v5 milestone Jul 29, 2026
Co-authored-by: Amp <amp@ampcode.com>
@danharrin

Copy link
Copy Markdown
Member

@m-develops I’ve pushed some changes to use Laravel’s existing Vite nonce instead of introducing a separate Filament API. It seems like Livewire already reads the nonce from Vite, so this keeps Filament and Livewire on the same value. This also works for apps using Mix, since the Vite facade is only being used to store the nonce.

I’ve updated the tests and docs to match. What do you think of this approach?

Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	docs/09-advanced/02-assets.md
Clarified information about Vite facade and frontend tooling compatibility.

Signed-off-by: Dan Harrin <git@danharrin.com>
@m-develops

Copy link
Copy Markdown
Author

Agreed this is better.

One question: the last docs commit removed the aside about SPA mode needing Livewire 4.2.2 or later. Earlier versions do not preserve the active document's nonce during wire:navigate, so scripts get blocked on navigation in panels with SPA mode enabled. Was that intentional, or worth keeping as a warning?

Happy to rebase the follow-up phases (inline styles, then Alpine's unsafe-eval) on this approach once you merge.

@danharrin

danharrin commented Sep 14, 2026

Copy link
Copy Markdown
Member

I just find that those docs refs to specific versions end up becoming out of date, and users debug by updating dependencies anyway, so I don't think it's worth mentioning. We don't even mention what versions of Filament support this feature anyway.

@m-develops

m-develops commented Sep 14, 2026

Copy link
Copy Markdown
Author

@danharrin one thing from running your changes locally on 781f01dc.

The inline data script is no longer byte identical when no nonce is configured.

Js::getHtml() is exact, but in assets.blade.php the @if inside the tag leaks the literal spaces around the directive:

<script >                  <!-- no nonce configured -->
<script  nonce="abc123" >  <!-- nonce configured -->

against <script> on 5.x. Cosmetic, but the PR promises byte identical output when the feature is off, so I thought it worth flagging. This keeps your approach and the guarantee:

<script{{ filled($nonce) ? new Illuminate\Support\HtmlString(' nonce="' . e($nonce) . '"') : '' }}>

Your reply on the SPA aside crossed with this comment. Understood on not pinning versions in the docs, dropping it.

For what it is worth: JsAssetTest passes, phpstan is clean, and the Support suite is green here apart from helpersTest > class discovery, which fails on plain 5.x in my checkout too.

@m-develops

Copy link
Copy Markdown
Author

Correction on the snippet above: it does not survive npm run prettier, which composer cs runs. The Blade plugin turns {{ … }} into a placeholder and then fails on the tag name, and a no-space @if variant fails the same way. So the choice is really: keep <script > as it is now, or wrap the tag in {{-- format-ignore-start --}}{{-- format-ignore-end --}}, which Filament already does in sidebar.blade.php. Your call, both are fine by me. Sorry for the noise.

@m-develops

Copy link
Copy Markdown
Author

@danharrin ignore the format-ignore suggestion above, there is a better answer and it is already in Filament.

@trim fixes it in one line:

@php
    $cspNonce = Vite::cspNonce();
    $cspNonceAttribute = filled($cspNonce) ? new HtmlString(' nonce="' . e($cspNonce) . '"') : '';
@endphp

<script @trim {{ $cspNonceAttribute }}>

Your Blade::extend() in SupportServiceProvider strips the whitespace around @trim after the view compiles, so Prettier sees the space it needs in the source and the browser gets <script> with no nonce configured and <script nonce="abc123"> with one. Verified both with Blade::render() and prettier --check.

While testing this I found the follow-up work needs the same tag in six more places, and that raised a question worth asking now rather than in the next PR.

Would you take a @filamentNonce directive? Registered next to @filamentScripts, so call sites become:

<script @trim @filamentNonce>

The case for it: the filled() check and the two @php lines otherwise repeat in every view that writes a script, and plugin authors currently have no idiom at all, the docs just tell them to add the attribute themselves. A directive gives them one token.

The case against: you have just deleted Filament's CSP API on purpose, and this is Filament API for CSP again, even if it is only view sugar resolving to Vite::cspNonce().

I lean towards adding it, but it is your call and the follow-up PR works either way. Without it, the per-view variable stays, which is still only two lines and keeps everything byte identical.

One more thing from the same round of testing, relevant to sizing that follow-up: six of the thirteen inline <script> tags in Filament's views sit inside Livewire's @script directive. Livewire captures those into the component payload and evaluates the inner content with Alpine, discarding the tag attributes, so a nonce there never reaches the browser. They belong with the Alpine unsafe-eval work instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants