Setting a Content Security Policy from PHP
AI generated
<?php
8.4
PHP · Security · HTTP Headers · XSS Protection
Setting a Content Security Policy from PHP
Directives, nonces and report only without a framework

A Content Security Policy drastically reduces the risk of cross site scripting, yet many PHP applications either skip it entirely or copy a rigid policy straight from a tutorial. Generating the policy directly from PHP, with per request nonces and a controlled rollout through report only mode, gives you a protection mechanism that fits the application instead of blocking it.

17 min read header() · nonce · hash · report only PHP 8.x · framework agnostic

1. Why a Content Security Policy makes sense in PHP

A Content Security Policy is an HTTP header that tells the browser which sources are allowed to provide scripts, styles, images and other resources. Unlike server side output encoding, a Content Security Policy acts as a second line of defense: even if an XSS vulnerability slipped through the code, the browser can still prevent an injected script from executing because it does not match an allowed source. Exactly this redundancy makes the Content Security Policy one of the most effective single security headers available.

In PHP applications, the Content Security Policy is frequently either skipped entirely, misunderstood as a purely frontend concern, or copied rigidly from a blog post, immediately blocking the application's own inline scripts and third party widgets. Both outcomes are unsatisfying. The better approach is to treat the Content Security Policy as a value generated by PHP that adapts per request, includes nonces for legitimate inline scripts and gets rolled out in a controlled way through report only mode before it is enforced.

2. Understanding the key CSP directives

Before assembling a Content Security Policy from PHP, it helps to know which directives are available. default-src is the fallback for any resource type not explicitly configured. script-src controls where JavaScript may be loaded from and is usually the most critical directive because it acts directly against XSS. style-src governs CSS sources, img-src image sources, connect-src the targets of fetch and XHR, and frame-ancestors replaces the older X-Frame-Options header for clickjacking protection.

Values inside a directive are separated by spaces: 'self' allows the current origin, 'none' blocks everything, and concrete domains such as https://cdn.example.com allow exactly that source. The keyword 'unsafe-inline' disables inline protection completely and should be avoided in a modern Content Security Policy, because it turns off the exact mechanism the policy is meant to provide. Instead, nonces or hashes are used, shown in the next section.


<?php

declare(strict_types=1);

// Minimal but production-viable Content Security Policy directives
final class CspDirectives
{
    /**
     * Build the base directive map before nonces are injected.
     *
     * @return array<string, list<string>>
     */
    public static function baseline(): array
    {
        return [
            'default-src' => ["'self'"],
            'script-src'  => ["'self'"],
            'style-src'   => ["'self'"],
            'img-src'     => ["'self'", 'data:'],
            'connect-src' => ["'self'"],
            'frame-ancestors' => ["'none'"],
            'object-src'  => ["'none'"],
            'base-uri'    => ["'self'"],
        ];
    }
}

The actual header is sent with the PHP function header() before any output has been sent to the browser. The header name is Content-Security-Policy, the value is a semicolon separated list of directives. Important: header() must be called before the first byte of output, otherwise PHP raises a "headers already sent" warning and the Content Security Policy is never sent. In modern applications the header is therefore set centrally in a bootstrap or middleware, not scattered across individual controllers.

For migration and debugging purposes there is the alternative Content-Security-Policy-Report-Only header, covered in more detail in the next section. Both headers can be sent in parallel: a strict report only header to test a new Content Security Policy, and a slightly looser enforced header that is already production ready. This way a tightening of the Content Security Policy can be prepared risk free without endangering the current application.


<?php

declare(strict_types=1);

/**
 * Serialize directive array into a CSP header value
 * and send it as an enforced Content-Security-Policy header.
 *
 * @param array<string, list<string>> $directives
 */
function sendCspHeader(array $directives): void
{
    $parts = [];
    foreach ($directives as $name => $sources) {
        $parts[] = $name . ' ' . implode(' ', $sources);
    }

    // Must run before any output — no whitespace, no BOM before this call
    header('Content-Security-Policy: ' . implode('; ', $parts));
}

$directives = CspDirectives::baseline();
sendCspHeader($directives);

4. Generating a nonce based policy per request

A nonce is a random, single use value generated fresh for every request, appearing both in the CSP header and as an attribute on every allowed <script> tag. Only scripts with a matching nonce are executed by the browser, all other inline scripts, especially ones injected by an attacker, are blocked. For a secure Content Security Policy with nonces it is crucial that the value is generated cryptographically securely, usually with random_bytes() followed by base64 encoding.

The nonce must never be reused, neither across multiple requests nor within the same request for different purposes. It is generated once per page load, passed to every legitimate inline script in the template and simultaneously injected into the script-src directive of the Content Security Policy. It is equally important not to generate the nonce in a predictable pattern, for instance from a timestamp, because that would defeat the entire protection.


<?php

declare(strict_types=1);

final class CspNonceGenerator
{
    private string $nonce;

    public function __construct()
    {
        // 16 random bytes -> 128 bit of entropy, base64 encoded for the header
        $this->nonce = base64_encode(random_bytes(16));
    }

    public function value(): string
    {
        return $this->nonce;
    }

    /**
     * Build script-src directive value with the current request nonce.
     *
     * @return list<string>
     */
    public function scriptSrc(): array
    {
        return ["'self'", sprintf("'nonce-%s'", $this->nonce)];
    }
}

$csp = new CspNonceGenerator();
$directives = CspDirectives::baseline();
$directives['script-src'] = $csp->scriptSrc();
sendCspHeader($directives);

// In the template: <script nonce="<?= htmlspecialchars($csp->value()) ?>">...</script>

5. Hash allowlisting for static inline scripts

Not every inline script changes per request. For fully static inline scripts, such as a fixed tracking snippet, a hash is often more practical than a nonce. The Content Security Policy supports 'sha256-...' values containing the base64 encoded SHA-256 hash of the exact script content. If even a single character in the script changes, for instance through accidental formatting, the hash no longer matches and the browser blocks the script.

The advantage of hashes over nonces is that they can be cached, since they do not need to be recomputed per request. The downside: every change to the script content requires a manual update of the hash value in the Content Security Policy, which becomes impractical for frequently changing code. In practice, hashes for stable third party snippets are therefore combined with nonces for dynamically generated inline scripts.


<?php

declare(strict_types=1);

/**
 * Compute the CSP hash source expression for a static inline script.
 */
function cspHashSource(string $scriptContent): string
{
    $hash = base64_encode(hash('sha256', $scriptContent, true));
    return sprintf("'sha256-%s'", $hash);
}

$trackingSnippet = "console.log('page view tracked');";
$directives = CspDirectives::baseline();
$directives['script-src'] = ["'self'", cspHashSource($trackingSnippet)];
sendCspHeader($directives);

// Any modification of $trackingSnippet — even whitespace — invalidates the hash

6. Report only mode and violation reporting

Before a strict Content Security Policy is enforced, the Content-Security-Policy-Report-Only header is recommended. It blocks nothing but reports every violation to a configured report-uri or the more modern report-to endpoint concept. This makes it possible to observe in production which resources are actually loaded, without an overly strict ruleset breaking the application for real users.

The report only endpoint in PHP receives a JSON payload via POST and should log violations in a structured way, for example grouped by blocked directive and blocked URI. After a few days in report only mode a clear picture usually emerges: which third party scripts are still missing from the Content Security Policy, which inline handlers in legacy code need to be cleaned up. Only then does the report only header become an enforced header.


<?php

declare(strict_types=1);

// csp-report-endpoint.php — receives browser CSP violation reports
header('Content-Type: application/json');

$raw = file_get_contents('php://input');
/** @var array{'csp-report': array<string, mixed>}|null $payload */
$payload = json_decode($raw ?: '', true);

if (isset($payload['csp-report'])) {
    $report = $payload['csp-report'];
    error_log(sprintf(
        '[CSP-VIOLATION] directive=%s blocked=%s document=%s',
        $report['violated-directive'] ?? 'unknown',
        $report['blocked-uri'] ?? 'unknown',
        $report['document-uri'] ?? 'unknown'
    ));
}

http_response_code(204);

7. Gradual rollout in grown applications

In a grown PHP application with years of legacy code, a strict Content Security Policy can rarely be introduced overnight. The proven rollout path starts with a very permissive policy in report only mode that simply observes. Afterward, tightening happens area by area: first object-src 'none' and frame-ancestors, because these rarely need legitimate exceptions, then script-src with nonces on the most critical pages such as login and checkout.

A pragmatic intermediate step is to enforce the Content Security Policy only for new features first and leave the rest of the application in report only mode until the reported violations have been addressed. It is important not to ignore the reporting, but to regularly evaluate the collected data and feed it into the next tightening stage of the Content Security Policy.

8. Common mistakes when using CSP in PHP

The most common mistake is leaving 'unsafe-inline' in place out of convenience, because otherwise existing inline scripts would break. This renders the Content Security Policy ineffective against exactly the attack class it is meant to prevent. A second mistake is setting the header after output has already been sent, which produces a "headers already sent" warning in PHP and silently drops the header if errors are not surfaced in logs.

A third mistake concerns nonce reuse across multiple requests, for instance by caching the rendered HTML including the nonce value. If a cached page carrying the same nonce is served multiple times, an attacker can reuse the value from an earlier response and bypass the Content Security Policy. Nonces and full page caching only coexist safely if the cache dynamically reinserts the script tag with the nonce on every delivery, rather than treating it as a static part of the cached page.

9. CSP strategies compared

Depending on the use case, different mechanisms within a Content Security Policy are better suited than others. The overview below shows when which approach is the better choice.

Mechanism Insecure / Ineffective Recommended Approach Benefit
Dynamic inline script 'unsafe-inline' 'nonce-...' per request Attacker cannot guess the nonce
Static tracking snippet 'unsafe-inline' 'sha256-...' hash Cacheable, no per request overhead
Rollout in legacy app Enforce immediately Report only first No surprise outages
Clickjacking protection X-Frame-Options only frame-ancestors 'none' More granular, replaces legacy header
Detecting violations Manual testing Report endpoint with logging Visibility across real users

In practice these mechanisms overlap: a mature Content Security Policy combines nonces for dynamic inline scripts, hashes for stable snippets and a permanently active report endpoint that keeps logging violations even after rollout, so new regressions are noticed immediately.

Mironsoft

PHP security audits, header hardening and Magento/Hyvä integration

Rolling out a Content Security Policy without outages?

We analyze existing PHP applications, build a tailored Content Security Policy with nonces and hashes, and accompany the rollout from report only through to full enforcement, without breaking production features.

CSP audit

Inventory of all inline scripts, third party sources and exceptions

Nonce integration

Wiring request based nonces into templates and middleware cleanly

Report only rollout

Controlled tightening with violation monitoring instead of a big bang switch

10. Summary

Setting a Content Security Policy from PHP is not a rigid header copied from the internet, but a process: define directives, send the header before any output, generate nonces per request for dynamic inline scripts and use hashes for stable snippets. Report only mode allows risk free testing in production before the Content Security Policy actually blocks anything. In grown applications the rollout happens gradually, directive by directive, with continuous violation reporting instead of a single big bang switch.

The biggest payoff of a cleanly implemented Content Security Policy lies in the second line of defense against XSS: even if a vulnerability slipped through the code, the browser prevents execution of the injected script because it matches neither a nonce, a hash, nor an allowed source. This redundancy costs only a few lines of PHP code but reliably prevents entire attack classes.

Setting a Content Security Policy from PHP — The essentials at a glance

Sending the header

header('Content-Security-Policy: ...') before any output, centrally in a bootstrap or middleware rather than scattered across controllers.

Nonce instead of unsafe-inline

Generate per request with random_bytes(), never reuse, never combine with a static full page cache.

Hash for static snippets

sha256 hash of the exact script content, cacheable but requires manual updates on code changes.

Report only first

Observe violations before enforcing. Keep reporting permanently active even after rollout.

11. FAQ: Setting a Content Security Policy from PHP

1Why CSP if the code is XSS safe?
Second line of defense. Overlooked bugs or third party mistakes are still blocked because the script has no allowed source.
2Meta tag instead of header possible?
Only partially. frame-ancestors and report-uri only work as a real HTTP header via header().
3Nonce not working with page cache?
Cached pages serve the same nonce repeatedly. It must be dynamically reinserted on every delivery.
4Is unsafe-inline ever okay?
Only as a brief transition during migration, never permanently. It disables inline protection completely.
5How do I test a new policy safely?
Use Content-Security-Policy-Report-Only. Blocks nothing but reports all violations to a report endpoint.
6How do I generate a secure nonce?
base64_encode(random_bytes(16)). Never derive it from a timestamp or predictable source.
7Nonce or hash, when which?
Nonce for dynamic inline scripts per request. Hash for immutable, static snippets, more cacheable.
8Do I still need X-Frame-Options?
As a fallback yes. frame-ancestors is more modern and granular, but older clients partially ignore it.
9Why is my header not being sent?
header() was called after output was already sent. Must be set before any output, including accidental whitespace.
10How do I handle third party scripts?
Explicitly allow in the matching directive, e.g. script-src https://cdn.example.com. Review scripts that load further inline code first.