CSP nonce in Symfony: NelmioSecurityBundle or a small subscriber of your own
A strict Content-Security-Policy breaks AssetMapper's inline scripts until every one of them carries a per-request nonce. Here is how NelmioSecurityBundle does it, how a single event subscriber does it, and what each one leaves out. Checked on Nelmio v3.9.0.
You add a Content-Security-Policy header with script-src 'self',
reload, and every Stimulus controller on the page is dead. The browser
console says Refused to execute inline script because it violates the
following Content Security Policy directive, once for each inline script
that importmap() printed.
AssetMapper renders the import map and the line that loads your entrypoint
as inline <script> tags, and a strict policy blocks inline scripts. There
are three ways out. 'unsafe-inline' lets them run, and with them any
script an attacker manages to inject, which is the attack the header was
there to stop. A hash allows one exact script and breaks when its content
changes. A nonce is a random value generated for each response, written
both in the header ('nonce-…' in script-src) and on every inline tag
you trust (nonce="…"). The browser runs the tags whose nonce matches,
and nothing else inline. For a Symfony app that renders HTML with Twig,
the nonce is the usual answer.
This article compares the two common ways to produce that nonce: the
NelmioSecurityBundle, and an event subscriber you write yourself. Facts
about the bundle were checked on Packagist and on its v3.9.0 tag on
October 8, 2026. Disclosure: I sell ShipAnvil, a Symfony SaaS kit that
took the second road. It is described at the end.
With NelmioSecurityBundle
nelmio/security-bundle v3.9.0 was released on February 23, 2026. It
supports Symfony 5.4 to 8 and PHP 7.4 and later, and it pulls in
ua-parser/uap-php. It is the long-standing answer to "security headers
in Symfony".
The policy goes in configuration:
# config/packages/nelmio_security.yaml
nelmio_security:
csp:
enforce:
default-src: ['self']
script-src: ['self']
object-src: ['none']
base-uri: ['self']
And the nonce goes in the template, through the bundle's Twig function:
{{ importmap('app', {nonce: csp_nonce('script')}) }}
Calling csp_nonce('script') is what makes the bundle add the
'nonce-…' source to script-src for that response. A page that never
calls it gets no nonce. The same function with 'style' covers inline
<style> tags.
The bundle does much more than CSP, which is the main reason to choose it:
- A report endpoint: point
report-uriat the bundle's controller and violations are logged, with filters for known false positives such as scripts injected by browser extensions. - Hashes for inline blocks you cannot tag, through
{% cspscript %}and{% cspstyle %}. - Framing rules per path and per host, external redirect detection, forced
HTTPS with HSTS, signed cookies,
Referrer-Policy,Permissions-Policyand the cross-origin isolation headers. - A
SecurityHeadersAssertionsTraitfor functional tests (assertCspHeader(),assertFrameOptions(),assertStrictTransportSecurity()…).
With a subscriber of your own
If what you want is a fixed set of headers and a nonce, the whole thing is
one listener on kernel.response and one Twig function. A trimmed
version:
final class SecurityHeadersSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly CspNonce $nonce,
#[Autowire(param: 'app.content_security_policy')]
private readonly string $policy,
) {
}
public static function getSubscribedEvents(): array
{
// After controllers, before the web profiler toolbar (-128),
// which appends its own nonces to an existing CSP header.
return [KernelEvents::RESPONSE => ['onResponse', -10]];
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$headers = $event->getResponse()->headers;
if (!$headers->has('Content-Security-Policy')) {
$headers->set(
'Content-Security-Policy',
str_replace('{nonce}', $this->nonce->nonce(), $this->policy),
);
}
}
}
CspNonce generates 16 random bytes on first use, stores them in an
attribute of the main request, and returns the same value for the rest of
that request. The Twig function csp_nonce() reads it from there, so the
template and the header can never disagree. The policy itself is one
string parameter with a {nonce} placeholder:
parameters:
app.content_security_policy: >-
default-src 'self';
script-src 'self' 'nonce-{nonce}';
object-src 'none';
base-uri 'self';
frame-ancestors 'self'
Three details decide whether this works:
- The listener priority. The web profiler adds its toolbar's nonces to whatever CSP header it finds, at priority -128. Set your header after that and the toolbar is blocked in dev.
- Only the main request. Sub-requests (fragments,
render(controller())) must not overwrite the header or generate a second nonce. - A header set by a controller wins. A page that embeds a third party, or a page that must be framed, sets its own policy and the subscriber leaves it alone.
Then one functional test that reads the nonce from the header and checks
that the <script type="importmap"> tag carries the same value, and a
second one that makes two requests and checks the nonce changed. That
second test is the one that catches the worst mistake below.
What bites either way
- A cached page reuses its nonce. If a reverse proxy or the HTTP cache stores a full HTML response, every visitor gets the same nonce, and the nonce protects nothing. Pages carrying a nonce must not be shared-cached.
- Third-party scripts need their origin. A payment overlay or an
analytics tag loaded from another domain is blocked until its origin is
in
script-src, and often inconnect-srcandframe-srctoo. Test with the console open: every violation is logged there. - Styles are harder than scripts. Turbo's progress bar and Stimulus
controllers that set
styleattributes need'unsafe-inline'instyle-src, or a lot of rework. Inline scripts are where injected code runs, so that is the half to lock down first. - HSTS only over HTTPS. Send it on HTTPS responses only, and add
includeSubDomainsonly once every subdomain is served over HTTPS.
Which one
Take the bundle if you want violation reports, hashes, per-path framing or
redirect checks: rewriting those is not worth it. Write the subscriber if
the policy is a handful of headers you want to read in one file and test
like the rest of your code. The two Twig functions share the name
csp_nonce, so templates written for one move to the other with little
change.
What ShipAnvil does
ShipAnvil is a complete Symfony 7.4 application you start your SaaS from, and it ships the subscriber version, on by default:
- A strict, nonce-based CSP on every page, with the policy in one YAML
parameter. The base layout already passes the nonce to
importmap(), so AssetMapper and Stimulus work under it (AssetMapper and Tailwind without Node). X-Content-Type-Options,X-Frame-Options,Referrer-Policyand aPermissions-Policythat turns off camera, microphone and geolocation, on every response. HSTS is sent on HTTPS responses only.- The checkout overlay's origins already allowed, so payment works under the policy.
- The web profiler toolbar keeps working in dev.
- Functional tests for the headers, the nonce match with the import map, a fresh nonce on every request and HSTS only over HTTPS.
What it does not do: there is no violation report endpoint, no hashes,
no signed cookies and no external redirect detection, and style-src
keeps 'unsafe-inline' for the reason above. The kit's csp_nonce()
accepts Nelmio's directive argument: if you move to the bundle later, you
remove the kit's subscriber and Twig function, and the templates stay as
they are.
The live demo runs under that CSP: open the developer tools and look at the response headers. Price and license terms are on the pricing page. If you are comparing whole kits, the Symfony SaaS boilerplate comparison checks every living option the same way.