Laravel#
This page walks the Laravel adapter from composer require to a fully wired app: install the adapter, publish the config, trust the proxy, then turn the knobs. The adapter is a service provider, two always-on middlewares, an optional request-forgery guard, and a config file. It only wires and configures: every rule (what the headers say, how a nonce is made, how a CSP is merged, and how a request-forgery check is applied) lives in the core, so Laravel and raw PHP cannot drift apart. The suite runs against Laravel 13 with Orchestra Testbench. Helmet is the band, and the name fits: put it on before you ship.
Install the adapter#
Strap It On:
composer require waynestate/helmet
Package discovery registers Waynestate\Helmet\Laravel\HelmetServiceProvider, so there is nothing to add to bootstrap/providers.php. The provider merges the helmet config and joins the HTTP kernel's middleware:
SecurityHeaderssends the headers in The headers and stripsX-Powered-By. By default it is global, so 404s and JSON routes carry them too.InjectRibbonadds the environment ribbon (The ribbon). It always stays in thewebgroup, because it edits HTML.RequestForgeryGuardis opt-in. Whenhelmet.csrf.enabledis true, it joins thewebgroup afterInjectRibbonand applies the check in The request-forgery guard.
They are added through the HTTP kernel, so they survive another package adding to the same group. Set HELMET_SCOPE=web to keep SecurityHeaders in the web group only; then routes outside web and unmatched 404s do not carry its headers.
At boot the provider also registers two route macros: Route::cspReport() for the CSP report endpoint (Customizing the CSP) and Route::helmetMetrics() for the metrics endpoint (Metrics). Both macros are always registered, but each route is added only when the app calls it, so both endpoints stay off by default.
Publish the config#
php artisan vendor:publish --tag=helmet-config
This copies config/helmet.php into your app. Do it: it is the best place to change the CSP, because the policy is a PHP array there, not JSON squeezed into .env. The config file is also the one place HELMET reads the environment (env(), plus HELMET_PRESET through Setting, which also checks Playbook), so php artisan config:cache keeps working.
Trust the proxy#
HELMET decides on its own whether a request was really HTTPS, from HELMET_PROXIES. Laravel has a separate trusted-proxy setting that drives url(), secure cookies and $request->isSecure(). The provider follows the same list: when the app has not set trustedproxy.proxies itself, it copies helmet.proxies over, so HELMET_PROXIES drives both. An app that sets trustedproxy.proxies keeps its own value, and an explicit trustProxies(at: ...) call in bootstrap/app.php still wins, because Laravel applies it before the config fallback.
The provider also narrows which forwarded headers Laravel believes from those proxies: X-Forwarded-For and X-Forwarded-Proto, and nothing else. Laravel's own default also trusts X-Forwarded-Host, X-Forwarded-Prefix and X-Forwarded-Port, and a client can pre-seed any of them on a platform whose proxy does not overwrite them, which poisons url(), redirects and password-reset links. Port is out for the same reason: Citadel's HA-Proxy never sends it at all, so a client-sent value would pass through untouched and change the port Laravel reports and the URLs it generates. If your app needs one of them, choose the set yourself with trustProxies(at: ..., headers: ...) in bootstrap/app.php (or TrustProxies::withHeaders()); HELMET then leaves your choice alone.
Citadel's HA-Proxy appends X-Forwarded-Proto instead of replacing it, so a client can arrive with two values, https, http. Symfony reads the first value of such a list (the client's), while HELMET reads the last (the one the closest proxy appended). To keep Laravel in agreement, the provider prepends a small middleware to the global chain that rewrites the header to its last value when the request comes from a trusted proxy. A single value is left alone, and requests from untrusted peers are ignored as before.
As in the Raw PHP path, use a preset or list only the address your front proxy connects from, never a whole private range. See Running off Citadel for the platform layout.
The /up health route is Laravel's own: keep health: '/up' in withRouting(). The adapter registers no route. Keep /up for liveness only: readiness checks (database, cache) belong on a separate /ready route, as described in Readiness is a separate check; do not put them in the DiagnosingHealth listener, which fires for /up.
Environment variables#
| Variable | Config key | Default | What it does |
|---|---|---|---|
HELMET_SCOPE |
helmet.scope |
global |
global puts SecurityHeaders in the kernel's global middleware; web keeps it in the web group only. InjectRibbon always stays in web. |
HELMET_PRESET |
helmet.preset |
none | A platform preset name (citadel or docker) that supplies the trusted proxies. Read from the environment, else from Playbook (see WSU values (Playbook)). HELMET_PROXIES wins when both are set. An unknown name throws, even when HELMET_PROXIES is set. |
HELMET_PROXIES |
helmet.proxies |
none | Comma-separated IPs or CIDR ranges allowed to set X-Forwarded-Proto. Wins over HELMET_PRESET. Also copied to trustedproxy.proxies when the app has not set it. An explicitly empty value (HELMET_PROXIES=) trusts nobody, even when HELMET_PRESET is set; only an unset variable falls back to the preset. |
HELMET_CSP |
helmet.csp |
the core default policy | A JSON policy array, merged over the default (see below). Empty or invalid JSON keeps the default. |
HELMET_HSTS_MAX_AGE |
helmet.hsts |
31536000 |
The HSTS max-age in seconds, sent on HTTPS only. It must not be negative. |
HELMET_HSTS_INCLUDE_SUBDOMAINS |
helmet.hsts_include_subdomains |
true |
Set false to omit the HSTS includeSubDomains directive. |
HELMET_HSTS_PRELOAD |
helmet.hsts_preload |
false |
Set true to append the preload directive to HSTS. Requires max-age of at least one year and includeSubDomains. |
HELMET_CSP_TRUSTED_TYPES_ENABLED |
helmet.csp_trusted_types.enabled |
false |
Set true to append Trusted Types directives to the report-only CSP header. |
HELMET_CSP_TRUSTED_TYPES_POLICIES |
helmet.csp_trusted_types.policies |
["default"] |
Comma-separated Trusted Types policy names. |
HELMET_NO_STORE_ENABLED |
helmet.no_store.enabled |
false |
Set true to send Cache-Control: no-store on matching responses. |
HELMET_NO_STORE_PATHS |
helmet.no_store.paths |
none | Comma-separated path prefixes that carry no-store. Empty means every response. |
HELMET_RIBBON |
helmet.ribbon |
true |
Set false to switch the ribbon off everywhere. |
HELMET_POLICY_PATH |
helmet.policy_path |
none | Path to a CREED policy.json. When set, the ribbon shows a version warning for the running PHP (and, in Laravel, the framework) version. A missing or invalid file shows no warning and never breaks the page. |
HELMET_DEV_ORIGIN |
helmet.dev_origin |
none | The Vite dev server origin, used in local only when the dev server is running. Must be http or https on localhost, 127.0.0.1 or [::1]. Overrides the hot file. |
HELMET_CSP_REPORT_URI |
helmet.csp_report_uri |
none | The URL browsers POST CSP violation reports to. Adds report-uri and report-to to the policy and a Reporting-Endpoints header. Must be https. |
HELMET_CSP_REPORT_ONLY |
helmet.csp_report_only |
false |
Set true to send the policy as Content-Security-Policy-Report-Only (observe violations without blocking them). |
HELMET_CSRF_ENABLED |
helmet.csrf.enabled |
false |
Set true to turn on the request-forgery guard for state-changing requests. |
HELMET_CSRF_ALLOW_SAME_SITE |
helmet.csrf.allow_same_site |
false |
Set true to let Sec-Fetch-Site: same-site pass. Off by default because sibling subdomains are still same-site. |
HELMET_CSRF_TRUSTED_ORIGINS |
helmet.csrf.trusted_origins |
none | Comma-separated origins allowed to make state-changing requests when the Origin fallback is used. |
HELMET_CSRF_EXCEPT |
helmet.csrf.except |
none | Comma-separated path patterns that skip the guard: an exact path, a prefix ending in *, or * for every path. |
HELMET_WARN_DEBUG |
helmet.warn_debug |
true |
Set false to stop the one boot-time warning the adapter logs when the app boots with app.debug true in production. |
Customizing the CSP in Laravel#
helmet.csp takes the same shape as the raw PHP csp option and follows the same rule: it is merged over the default, per directive. A directive you set replaces the default's, a directive you leave out keeps it, and null removes one. Open the published config/helmet.php and replace the $csp = SecurityHeaders::defaultCspPolicy(); line:
$csp = [
'img-src' => ["'self'", 'data:', 'https://images.example.edu'],
'form-action' => null,
];
If you would rather use the environment, HELMET_CSP takes the same array as JSON. In a .env file it needs double quotes and escaped inner quotes, which is the reason to publish the config instead:
HELMET_CSP="{\"img-src\":[\"'self'\",\"data:\"],\"form-action\":null}"
Invalid JSON is ignored and the default policy stays, so a typo in the environment cannot loosen the CSP. For the merge rule and report-only mode, see Customizing the CSP.
The nonce in Blade#
The middleware puts the per-request nonce on the request as the attribute helmet.csp_nonce, the same name the PSR-15 middleware uses. It also hands the nonce to Laravel's Vite helper, so @vite needs nothing: the script and style tags it print already carry the nonce the CSP header sends.
For an inline script, print the nonce from the Vite helper:
<script nonce="{{ Vite::cspNonce() }}">
// allowed by the nonce, and free to load more scripts (strict-dynamic)
</script>
In a controller it is $request->attributes->get('helmet.csp_nonce'). A new nonce is made for every request. Repetition is a fine Helmet song and a terrible nonce.
Vite and Inertia#
The middleware hands the per-request nonce to Laravel's Vite helper and injects a csp-nonce meta tag into <head>. @vite needs nothing. The meta tag lets Vite's dev runtime nonce the CSS and preload tags it adds after page load. When the request ends, HELMET resets the Vite nonce to a fresh random one, so a long-lived worker (Octane, Swoole, FrankenPHP) does not carry one request's nonce into the next.
A few rules keep the CSP honest:
- Nonce every inline script. The CSP has no
'unsafe-inline'for scripts. Every<script>without asrcneedsnonce="{{ Vite::cspNonce() }}". - No
'unsafe-eval'. The CSP never allows it. If your bundle needseval, fix the bundle. - No scripts in Inertia's
<Head>. Inertia's<Head>renders into the document head after the page loads. A<script>there would not carry the nonce, and the browser would block it. Put scripts in your layout, where@viteand the nonce reach them. - No second nonce-producing package. If another package calls
Vite::useCspNonce()after HELMET, the@vitetags carry a different nonce than the CSP header, and the browser blocks the scripts. The middleware logs a warning when it detects this. - No shared caching of nonced HTML. The nonce changes every request. A cached page would carry a stale nonce, and the browser would block its scripts. Do not cache HTML at a layer that shares it across requests.
- Do not replace
style-srcwith'unsafe-inline'. The policy adds the per-request nonce tostyle-srcandstyle-src-elem, and a browser ignores'unsafe-inline'once a nonce is present. An override that relies on it loses every inline style silently. Keep the nonce, move the styles into files, or give the inline styles a nonce.
In raw PHP, print the meta tag in <head> with the nonce helmet_boot() returned:
echo helmet_csp_nonce_meta($helmet['nonce']);
The Vite dev server is allowed in the local environment only, and only when it is running (a hot file exists). The origin comes from HELMET_DEV_ORIGIN when set, otherwise from the hot file. It must be http or https on localhost, 127.0.0.1 or [::1], with an optional port. An invalid origin is ignored and a warning is logged. In every other environment the dev server origin is never added.
The ribbon#
In local, development, dev, testing, test and staging, InjectRibbon adds <div class="helmet-ribbon" data-environment="staging">staging</div> right after the opening <body> tag of an HTML response, so nobody mistakes a test site for the real one. It never appears in production, on JSON or other non-HTML responses, or on HTML with no <body>. The package ships no stylesheet; style .helmet-ribbon in your own CSS. Even with HELMET_RIBBON=true, production never gets it, so there is no Aftertaste.
Version warnings. Point HELMET_POLICY_PATH at a CREED policy.json and the ribbon gains a warning line for each component version that is in its security-only phase or end of life. The raw PHP path checks the running PHP version; the Laravel adapter also checks the framework's major version. With no policy path there is no warning and no file read, and a missing, unreadable or invalid file shows no warning and never breaks the page. Style .helmet-ribbon-warning in your own CSS.
Raw PHP and Laravel agree#
Both paths call SecurityHeaders::build(), so for the same request and settings they send the same headers. A test pins that down: it compares what the Laravel middleware sends, name and value, with build(). The settings map like this:
| Setting | Raw PHP (helmet_boot()) |
Laravel |
|---|---|---|
| Trusted proxies | trusted_proxies |
HELMET_PROXIES / helmet.proxies |
| CSP overrides | csp |
HELMET_CSP / helmet.csp, same merge, null removes |
| HSTS max-age | hsts_max_age |
HELMET_HSTS_MAX_AGE / helmet.hsts |
| HSTS subdomains | hsts_include_subdomains |
HELMET_HSTS_INCLUDE_SUBDOMAINS / helmet.hsts_include_subdomains, default true |
| HSTS preload | hsts_preload |
HELMET_HSTS_PRELOAD / helmet.hsts_preload, default false |
| Trusted Types | csp_trusted_types |
HELMET_CSP_TRUSTED_TYPES_* / helmet.csp_trusted_types.*, default off |
| no-store | no_store |
HELMET_NO_STORE_* / helmet.no_store.*, default off |
| CSP report URI | csp_report_uri |
HELMET_CSP_REPORT_URI / helmet.csp_report_uri |
| CSP report-only | csp_report_only |
HELMET_CSP_REPORT_ONLY / helmet.csp_report_only, default false |
| COOP | coop |
HELMET_COOP / helmet.coop, default same-origin, null sends no header |
| CORP | corp |
HELMET_CORP / helmet.corp, default null (no header) |
| Permissions-Policy | permissions_policy |
HELMET_PERMISSIONS_POLICY / helmet.permissions_policy, default deny list, [] sends no header |
| Nonce | $helmet['nonce'] |
request()->attributes->get('helmet.csp_nonce') |
| Health check | health_path, answered by helmet_boot() |
Laravel's health: '/up' |
| Request-forgery guard | csrf option to helmet_boot() |
helmet.csrf.* / HELMET_CSRF_*; the middleware is registered only when enabled |
| Session hardening, JSON logs | applied by helmet_boot() |
not applied by the adapter; Laravel's session and logging config apply |
For session hardening in Laravel, use the framework's own session config: set secure, http_only, and same_site in config/session.php rather than expecting the adapter to apply raw-PHP SessionHardening. For structured logs, use Laravel's log channels, for example the stderr channel, rather than expecting the adapter to configure a raw-PHP logger. The first two security events (Security events) are written through the Log facade with the same message and context, and the third, helmet.debug_enabled_in_production, is written by the Laravel adapter at boot. The check runs at boot, not per request: the value is fixed for the life of the process, and a long-lived worker must not log it on every request. Both sides are checked strictly: app.debug must be the boolean true, and the environment must be exactly production. Opt out with helmet.warn_debug (env HELMET_WARN_DEBUG); a logging failure is swallowed and never breaks the boot.