emielburgman / symfony-security-headers
Security response headers and a nonce-based Content-Security-Policy for Symfony, with the whole policy as configuration.
Package info
bitbucket.org/emielburgman/symfony-security-headers
Type:symfony-bundle
pkg:composer/emielburgman/symfony-security-headers
Requires
- php: >=8.2
- symfony/config: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/http-foundation: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5|^11.0|^12.0
- twig/twig: ^3.0
Suggests
- twig/twig: Exposes csp_nonce() to templates.
This package is not auto-updated.
Last update: 2026-07-30 15:13:13 UTC
README
Security response headers and a nonce-based Content-Security-Policy for Symfony. The mechanism is the package; the entire policy is configuration.
MIT.
Why
Two of four Symfony projects on this host sent security headers at all, and the
two that did had each written the same three things by hand: mint one nonce per
request, assemble directives into a header, don't clobber what a controller
already set. Everything else about their policies differs, legitimately —
X-Frame-Options: SAMEORIGIN vs DENY, frame-ancestors 'self' vs 'none',
Stripe in form-action for one of them, Trusted Types disabled on one app's
admin because TinyMCE cannot run under it. A bundle that owned those values would
be wrong for someone immediately, so it owns none of them.
The nonce implementation is the one place the package picks a winner. One app
minted it in a KernelEvents::REQUEST listener at priority 255; the other
generated it lazily on first call. Lazy wins: with a listener, anything asking
before it runs gets an empty string, and an empty nonce="" in a template looks
exactly like a correct one until a browser refuses the script.
Install
composer require emielburgman/symfony-security-headers
// config/bundles.php
Emielburgman\SecurityHeaders\SecurityHeadersBundle::class => ['all' => true],
Then put the policy in config/packages/security_headers.yaml and use
{{ csp_nonce() }} on every <script> the templates emit — including
encore_entry_script_tags('app', null, '_default', { nonce: csp_nonce() }) and
<meta name="csp-nonce" content="{{ csp_nonce() }}"> if the app uses Turbo
(without that meta, scripts Turbo activates after a navigation carry a nonce the
loaded document's header never allowed).
Configuration
security_headers:
headers:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Strict-Transport-Security: 'max-age=63072000; includeSubDomains; preload'
# Withhold HSTS over plain HTTP. On by default: a max-age pinned into your
# browser from a local dev domain is a genuinely annoying thing to undo.
hsts_requires_secure: true
csp:
directives:
default-src: ["'self'"]
# {nonce} is replaced with 'nonce-<value>' per response. A policy
# that never names it never gets one.
script-src: ['{nonce}', "'strict-dynamic'"]
style-src: ["'self'", "'unsafe-inline'"]
object-src: ["'none'"]
# An empty list renders a valueless directive.
upgrade-insecure-requests: []
require-trusted-types-for: ["'script'"]
trusted-types: ['default', 'goog#html']
# Applied in order to any request whose path starts with the prefix.
overrides:
- path_prefix: /admin
remove: ['require-trusted-types-for', 'trusted-types']
- path_prefix: /loves
append:
style-src: ['https://fonts.googleapis.com']
font-src: ['https://fonts.gstatic.com']
# Only ever applied in that kernel environment, so an HMR server cannot
# reach production.
environment_appends:
dev:
connect-src: ['http://localhost:5173', 'ws://localhost:5173']
Directives render in configured order. Headers the application already set are
left alone, including Content-Security-Policy itself, so a controller that
needs its own policy for one response can just set it.
Key spelling
Write directive and header names either way — script-src or script_src,
X-Frame-Options or X_Frame_Options. Symfony's Config component rewrites -
to _ in prototyped array keys before any normalization closure can see them,
so the bundle accepts both and emits dashes. Neither a CSP directive nor an HTTP
header name legitimately contains an underscore, so the mapping is unambiguous.
This is not cosmetic: without it a policy configured with dashes reached the
browser as default_src, script_src, frame_ancestors — a header that looks
entirely plausible, is accepted silently, and enforces nothing. A CSP that
is quietly inert is worse than no CSP, because its presence is what stops people
checking. ConfigurationTest runs YAML-shaped config through the real extension
for exactly this reason; the pure CspPolicy tests never crossed that layer and
could not have caught it.
Two things worth knowing before you write a policy
'strict-dynamic' makes CSP3 browsers ignore your host allowlist in
script-src. Every static <script src> then needs the nonce — the entry
bundle's own tags included. Trust propagates to scripts a trusted script creates
via createElement, but not to ones injected with document.write, which is
why some ad and tag-manager paths break under it while the loader itself works
fine. Keep the host list as a fallback for pre-CSP3 browsers if you like; it
costs nothing and CSP3 ignores it.
A nonce disables 'unsafe-inline'. Under CSP2+ the moment a nonce appears in
script-src, 'unsafe-inline' is ignored — which is the point, but it means
every inline on* attribute stops working silently. There is nowhere to hang a
nonce on onclick=, so a delete button whose onclick="return confirm(…)" was
the only confirmation will delete on the first click with no prompt. Both adopting
apps had exactly that bug. Grep for handler attributes before switching a policy
over, and consider keeping a test that does.
Tests
composer install && vendor/bin/phpunit
CspPolicy is pure — a policy, a path and a nonce in, a string out — because a
CSP is the header where being subtly wrong is invisible in both directions: a
missing source silently kills a third-party script in the visitor's browser with
nothing logged server-side, and an over-broad source silently removes the
protection. Both are cheap to assert against a pure function and awkward through
a kernel.