CSP Level 3: Nonce-Based Content Security Policy Instead of unsafe-inline
AI generated
OWASP
0x00
Security · CSP · Browser Headers · XSS Protection
CSP Level 3
nonce-based Content Security Policy instead of unsafe-inline

A Content Security Policy with the unsafe-inline directive lets every inline script on the page run, whether it came from your own team or was injected by an attacker through cross-site scripting. That is not a minor caveat, it nearly guts the whole protective purpose of a CSP. CSP Level 3 solves this dilemma with a nonce value freshly generated on every request, unlocking legitimate inline scripts on a granular basis while every injected, unauthorized script stays blocked.

15 min read CSP Level 3 · Nonce strict-dynamic · XSS protection

1. The problem with unsafe-inline in practice

Many mature web applications carry inline scripts, whether for tracking snippets, dynamically generated configuration values, or legacy code that was never moved into external files. To equip such pages with a Content Security Policy at all, teams often reach for the quick fix script-src 'self' 'unsafe-inline', since a stricter policy would otherwise immediately produce a wall of console errors and broken functionality.

That shortcut carries a decisive downside: unsafe-inline permits every inline script without exception, regardless of who added it. If an attacker manages a cross-site scripting injection, say through insufficiently validated user input that lands unfiltered in the HTML, the browser executes the injected script just fine despite an active CSP. The policy protects only on paper in that case, offering no resistance against the most common form of XSS in practice.


// Symfony middleware: generate a nonce per request and inject it into the CSP header
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpFoundation\RequestStack;

final class CspNonceListener
{
    public function __construct(
        private readonly RequestStack $requestStack,
    ) {
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $nonce = $this->requestStack->getCurrentRequest()?->attributes->get('csp_nonce');
        if ($nonce === null) {
            return;
        }

        $policy = sprintf(
            "script-src 'self' 'nonce-%s' 'strict-dynamic'; object-src 'none'; base-uri 'self';",
            $nonce,
        );

        $event->getResponse()->headers->set('Content-Security-Policy', $policy);
    }
}

2. How the nonce mechanism actually works

A nonce is a randomly generated, cryptographically strong one-time value created fresh on every single HTTP response, never to be reused. The server places this value both in the CSP header as 'nonce-<value>' within the script-src directive and as a nonce attribute on every inline script tag that should be allowed to run.

The browser then executes only inline scripts whose nonce attribute matches the value stated in the header exactly. A script injected by an attacker has no way to know that value, since it is freshly generated server-side on every request and is not predictable for the attacker at the moment of injection. Even if the injection technically succeeds, the injected code lacks the correct, current nonce attribute, and the browser refuses to run it.

3. Secure nonce generation: what actually matters

The security of the entire mechanism rests entirely on generating the nonce value with a cryptographically secure random number generator, such as random_bytes() in PHP or the Web Crypto API in a browser context, and never with a predictable function like a plain timestamp or an incrementing counter. A predictable nonce guts the whole protection, since an attacker could simply insert it into their injected script tag.

Equally important is genuinely regenerating the nonce value on every single request and never reusing it across multiple responses, for instance by caching the full HTML response including the embedded nonce value. A nonce value served identically to every user through a full-page cache would be shared across the whole audience, and an attacker who visits the page once could read it and reuse it directly.

4. strict-dynamic for modern single-page applications

Modern JavaScript applications frequently load additional scripts at runtime, for example through dynamic imports or chunk files pulled in from a main bundle. A classic allowlist-based CSP would need to know and explicitly permit every one of these loaded script URLs individually, which quickly becomes impractical with modern code splitting and produces console errors for every new chunk name.

The strict-dynamic directive solves this by automatically extending the trust granted to a nonce-authorized script to every script that script itself loads at runtime via createElement and DOM insertion. Classic allowlist directives like 'self' are ignored by supporting browsers once strict-dynamic is active, which in practice means a combination of nonce and strict-dynamic delivers both maximum security and practical compatibility with modern bundling.

5. Fallback strategy for older browsers without strict-dynamic

Since not every browser supports strict-dynamic, the CSP specification itself recommends a layered directive, where older browsers respect a classic allowlist such as 'self' https://trusted-cdn.example, while modern browsers ignore that same allowlist thanks to strict-dynamic and follow the nonce mechanism instead. This combination works because CSP-capable but older browsers silently ignore unknown keywords like strict-dynamic, while modern browsers do exactly the opposite.

In practice, such a combined directive looks like this: script-src 'nonce-abc123' 'strict-dynamic' 'self' https://trusted-cdn.example. Older browsers read this line as a classic allowlist with a nonce exception, modern browsers activate the dynamic trust mechanism thanks to strict-dynamic and ignore the URL-based allowlist entirely. The page stays functional either way, at a very different but each time best-possible level of protection.

6. Common implementation mistakes in practice

A common mistake is a nonce value that is generated randomly in principle but ends up accidentally reused across multiple requests through a global application cache or middleware configuration, because generation only happens once at application startup instead of truly per request. This kind of mistake often goes unnoticed in testing, since the CSP keeps working technically while the actual security guarantee disappears entirely.

A second common mistake is setting the CSP header only on the main HTML response, while dynamically loaded content, such as AJAX-fetched partial pages or server-rendered fragments, ships without its own consistent nonce assignment. Especially with server-rendered components cached independently of the main request, the nonce mechanism needs careful design to avoid inconsistencies between the header and the embedded scripts.

7. Integrating with server-side templating

For the nonce value to be used consistently across the whole HTML document, it needs to be generated once per request and then referenced at every point in the template where an inline script sits. In Symfony, a request attribute set early in the kernel request cycle works well for this, making the value available both to the Twig template and to the response listener that sets the CSP header.

It is important to consistently add the nonce attribute to every single inline script tag in the template, because a single forgotten tag leads to a functionally broken script and a matching console error, not a silent security hole. Compared to unsafe-inline, this fail-closed property is a real advantage, since mistakes surface immediately instead of going unnoticed.

8. Rollout strategy with report-only mode

Switching from unsafe-inline to a nonce-based approach in an existing, mature application should not happen abruptly, but be tested step by step through the Content-Security-Policy-Report-Only header. In this mode, the browser reports violations against the policy without actually blocking them, enabling a risk-free trial run in production before the policy gets enforced.

A reporting endpoint, configured via the report-to directive, collects every violation during this trial phase and reliably shows which inline scripts in the codebase still lack a nonce attribute. Only once violation reports drop to zero over a sufficiently long period should the policy move from report-only mode into full enforcement.

9. Conclusion: nonce-based CSP as the standard for modern applications

A Content Security Policy with unsafe-inline is, in practice, little more than a formal gesture, since it leaves open precisely the attack class a CSP is supposed to protect against. The nonce-based approach from CSP Level 3 solves this dilemma by authorizing legitimate inline scripts on a granular, per-request basis, while injected code lacking a valid nonce stays reliably blocked.

Paired with strict-dynamic for modern code splitting and a step-by-step rollout through report-only mode, this approach can be introduced into mature applications without major risk. Anyone who genuinely treats a CSP as a protective control rather than a compliance checkbox will find it hard to avoid the nonce-based model in the long run.

Approach XSS protection Maintenance overhead Compatibility
unsafe-inline Practically no protection Very low Universal
Domain allowlist Limited, bypassable via JSONP endpoints High with many sources Good, but inflexible for code splitting
Nonce-based Strong, random per request Moderate, needs templating integration All modern browsers
Nonce + strict-dynamic Strong, covers dynamic loading Moderate, one-time setup Modern browsers, with fallback for older ones

Mironsoft

Security audits, OWASP-compliant hardening, and secure architecture

Applications that actually hold up against a real attack attempt?

We review existing applications for classic OWASP vulnerabilities, insecure authentication, and missing input validation, then build an architecture that structurally reduces attack surface instead of just patching individual symptoms.

Security Audit

Systematically checking OWASP Top 10, auth flows, and input validation for vulnerabilities.

Secure Architecture

Building rate limiting, encryption, and access controls correctly from the ground up.

Incident Readiness

Establishing logging, monitoring, and response processes for when things go wrong.

10. Summary

Nonce-based CSP under Level 3 at a glance

Core problem

unsafe-inline allows every inline script, including injected XSS code.

Solution

A nonce value regenerated per request authorizes legitimate scripts on a granular basis.

For SPAs

strict-dynamic extends trust to scripts loaded dynamically at runtime.

Rollout

Report-only mode surfaces missing nonce attributes before full enforcement.

11. FAQ: Nonce-based CSP under Level 3 at a glance

1Why is unsafe-inline problematic in a CSP?
Because the directive lets every inline script run without exception, regardless of origin. A script injected via cross-site scripting still executes normally despite an active CSP.
2What exactly is a CSP nonce?
A randomly generated, cryptographically secure one-time value created fresh on every HTTP response, placed both in the CSP header and as an attribute on every allowed inline script.
3Why must a nonce never be reused?
A reused, predictable nonce could be read by an attacker and inserted into an injected script tag, which would render the entire protection mechanism useless.
4What does strict-dynamic actually do?
It automatically extends the trust granted to a nonce-authorized script to every script that script itself loads at runtime, without requiring every loaded URL to be listed individually in the policy.
5Does strict-dynamic work in every browser?
No, older browsers ignore the unknown keyword and instead respect a classic allowlist specified alongside it, which creates a working fallback.
6How is the nonce value generated?
With a cryptographically secure random number generator such as random_bytes() in PHP, never with a predictable source like a timestamp or a sequential number.
7Can a full-page cache break the nonce mechanism?
Yes, if the entire HTML response including the nonce gets cached, every user receives the same value, which defeats the protection. The nonce must be freshly generated on every response.
8What is report-only mode?
A CSP mode where the browser reports violations against the policy without blocking them. It is useful for checking which inline scripts still lack a nonce attribute before full enforcement.
9What happens if an inline script in the template forgets the nonce attribute?
The browser blocks that script and logs a console error; the script simply does not run. The error is immediately visible instead of silently leaving a security gap.
10Is a nonce-based CSP worthwhile for classic server-side applications too?
Yes, as soon as any inline scripts exist in the template. Server-side templating such as Twig or Blade can generate the nonce value once per request and apply it consistently without difficulty.