Skip to content

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-uri at 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-Policy and the cross-origin isolation headers.
  • A SecurityHeadersAssertionsTrait for 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 in connect-src and frame-src too. Test with the console open: every violation is logged there.
  • Styles are harder than scripts. Turbo's progress bar and Stimulus controllers that set style attributes need 'unsafe-inline' in style-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 includeSubDomains only 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-Policy and a Permissions-Policy that 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.