Skip to content

Play 0 · every app, every snap

HELMET

HELMET stands for Headers, Errors, Logging, Monitoring, Environment, Trust.

Put it on before you ship.

The stack's baseline runtime. One small, framework-free PHP core sends the security headers, hardens the session, checks the proxy, answers /up and writes structured logs, before your app serves a single page. Raw PHP, Laravel and PSR-15 all wear the same one.

composer require waynestate/helmet

Open the playbook Pregame: three steps to kickoff

Five rushers, five blockers, zero sacks. Point at a call to see who blocks whom.

The playbook

Six plays, each one a protection that stops a named rusher. Laminated, because coffee happens on the sideline.

  1. Play 22: Nonce Dive

    CSP

    Blocks Injected script (XSS)

    A fresh nonce on every response, plus strict-dynamic. A script without the nonce does not run, and there is no 'unsafe-inline' or 'unsafe-eval' to sneak through.

    script-src 'self' 'nonce-…' 'strict-dynamic'

    New nonce every snap.

  2. Play 34: Frame Screen

    FRAMES

    Blocks Clickjacking

    frame-ancestors 'self' in the policy, and X-Frame-Options for older browsers. A stranger's page cannot frame yours and trick a click.

    X-Frame-Options: SAMEORIGIN

    No strangers in the frame.

  3. Play 47: Long Snap

    HSTS

    Blocks A downgrade to plain HTTP

    One year, every subdomain, sent only when the request really was HTTPS. Roll it out in stages; preload is the domain owner's call, so HELMET never adds it.

    Strict-Transport-Security: max-age=31536000; includeSubDomains

    Start short, then go long.

  4. Play 41: Session Lock

    SESSION

    Blocks Session fixation and cookie theft

    Strict mode, cookies only, HttpOnly and SameSite=Lax, set before the session starts. Secure follows the real scheme, and it refuses to run once a session is already active.

    session.use_strict_mode=1 cookie_httponly=1 cookie_samesite=Lax

    Strapped before kickoff.

  5. Play 58: Proxy Trap

    PROXIES

    Blocks A forged X-Forwarded-Proto

    Only a trusted proxy's address may say a request was https. A spoofed header from the internet is ignored, and no HSTS goes out on a request that was not really secure. Trust the proxy's address, never a whole private range.

    helmet_boot(['preset' => 'citadel'])

    The Unsung hero of the line.

  6. Play 00: Victory Formation

    /UP

    Blocks A slow app called dead

    GET or HEAD /up answers 200 ok with Cache-Control: no-store before your code runs, so the load balancer sees a live container even while the app is busy.

    GET /up → 200 ok

    Take a knee. Still up.

Equipment check: every response, the full kit

One shared function builds these seven, so raw PHP and Laravel send the same headers, name and value. A test pins it down. Cross-Origin-Resource-Policy suits up only when you call for it.

  • Content-Security-Policythe WayneUI default policy, a fresh nonce and strict-dynamic
  • X-Content-Type-Optionsnosniff
  • X-Frame-OptionsSAMEORIGIN
  • Referrer-Policystrict-origin-when-cross-origin
  • Permissions-Policycamera=(), geolocation=(), microphone=(), payment=(), usb=()
  • Cross-Origin-Opener-Policysame-origin, configurable
  • Strict-Transport-Securityone year with includeSubDomains, on HTTPS only

Even on a fumble. The middleware sends the headers when the inner handler throws, and X-Powered-By is stripped from every response. Milquetoast is a Helmet song. It is not a Content-Security-Policy.

Pregame: three steps to kickoff

Pick your app's shape on the board. The same commands work in PowerShell, bash and zsh: Composer and Artisan don't care which shell snapped the ball.

Scoreboard: pregame, three steps to kickoff. HELMET 7, rushers 0: seven security headers sent, zero sacks allowed.

Raw PHP

  1. Step 1: Strap it on

    One package, no framework needed.

    composer require waynestate/helmet
  2. Step 2: Call it once

    At the top of the front controller, before anything is echoed. Headers, session, proxy check and /up happen right here.

    use function Waynestate\Helmet\helmet_boot;
    
    $helmet = helmet_boot(['preset' => 'citadel']);
  3. Step 3: Nonce your scripts

    $helmet['nonce'] is this request's nonce. The meta tag lets Vite's runtime use it too.

    echo helmet_csp_nonce_meta($helmet['nonce']); // in <head>
    
    <script nonce="<?= $helmet['nonce'] ?>">…</script>

Laravel

  1. Step 1: Strap it on

    Package discovery registers the provider and both middlewares. Nothing to add to bootstrap/providers.php.

    composer require waynestate/helmet
  2. Step 2: Publish the config

    The best place to change the CSP: a PHP array, not JSON squeezed into .env.

    php artisan vendor:publish --tag=helmet-config
  3. Step 3: Pick the platform

    In .env. Keep health: '/up' in withRouting(); @vite already carries the nonce.

    HELMET_PRESET=citadel

PSR-15

  1. Step 1: Strap it on

    The same core, mounted in a middleware pipeline.

    composer require waynestate/helmet
  2. Step 2: Line up the middleware

    /up first, then the headers. Headers go out even when the inner handler throws.

    $factory = new Psr17Factory();
    $proxies = new TrustedProxies(Presets::proxies('citadel'));
    
    $middleware = [
        new HealthCheck($factory, $factory, '/up'),
        new SecurityHeadersMiddleware($proxies),
    ];
  3. Step 3: Read the nonce

    Downstream handlers get the same nonce the header carries.

    $request->getAttribute('helmet.csp_nonce')

Off the WSU platform? Skip the preset and list the one address your front proxy connects from, for example 'trusted_proxies' => ['203.0.113.10']. Never a whole private range: a broad range also trusts the other containers on the network. An unknown preset name throws at boot, so a typo fails loudly instead of trusting nothing.

The film room

What HELMET writes down while the game is on, so you can watch the tape afterwards.

  1. Logs, one line each

    {"message":"login failed",
     "context":{"user":"student42",
      "password":"***"},
     "level_name":"WARNING",
     "channel":"app"}

    Monolog JSON, one event per line. Passwords, tokens and session IDs come out as ***, and if the log file can't be written it falls back to stderr instead of taking the request down.

  2. Version warnings

    Laravel 11 is end of life and
    no longer receives security
    updates. Upgrade to a
    supported version.

    Read from CREED's policy.json. A version in its security-only phase or past end of life gets a plain warning; an active or unknown one gets none. The date is injected, so the boundaries are testable.

  3. Metrics, off by default

    # HELP helmet_info HELMET is running.
    # TYPE helmet_info gauge
    helmet_info{php_version="8.4",
     helmet_version="0.1.0"} 1

    A Prometheus endpoint you turn on with HELMET_METRICS_ENABLED, behind a bearer token or an IP allow list. With neither set it answers 404, so nobody hands their metrics to the whole network.

  4. The ribbon

    In local, testing and staging, Laravel apps get <div class="helmet-ribbon"> right after <body>, so nobody mistakes the scrimmage for the game. Never in production, never on JSON. No Aftertaste.

Coach's rules for Vite and Inertia

The CSP stays honest only if the bundle plays along.

  1. Nonce every inline script. There is no 'unsafe-inline' for scripts; @vite tags already carry the nonce.
  2. No 'unsafe-eval'. If your bundle needs eval, fix the bundle.
  3. No scripts in Inertia's <Head>. They render after load without the nonce, and the browser blocks them. Put scripts in the layout.
  4. One nonce per request, from one package. A second Vite::useCspNonce() splits the header from the tags; HELMET logs a warning when it sees one.
  5. No shared caching of nonced HTML. A cached page carries yesterday's nonce. Repetition is a fine Helmet song and a terrible nonce.

Practice squad

On the backlog in HELMET's best practices, not in 0.1. They suit up when they're ready.

  • A readiness check, separate from /up
  • Cross-Origin-Opener-Policy and -Resource-Policy, tested against WayneUI's assets
  • Trusted Types, in Report-Only first, for apps with a JS framework
  • A Clear-Site-Data helper for logout
  • A CSRF check for raw PHP, from Origin and Sec-Fetch-Site
  • A global error handler: detail in the log, a generic page for the user

Wayne State app stack · 0.1 season

WELD depth chart

HELMET plays on the line for every app in the stack. Here's who lines up beside it.

On the field

What runs inside every app

  • WayneUIRenders every page: the design system, tokens and components.
  • SESAMESnaps the sign-in: CAS today, the gateway later.
  • HELMET you are hereProtects every snap: headers, session, proxies, /up, logs.
  • WELDEvery part already joined, in one Laravel + Inertia/Vue app.

Coaches and officials

What keeps the code honest

  • CREEDThe written standards, and the policy.json HELMET reads.
  • PROCTOROne command that checks an app against the standards.
  • PinstripeThe coding standard: Pint, PHPStan, ESLint and Prettier.
  • PREFLIGHTShared CI from GitLab CI/CD components.
  • ADVISOR nextThe voice in your headset: an MCP server that knows the stack.

Sideline and front office

How apps start, ship and grow

  • SourdoughStarters you shape yourself: raw PHP, Blade or Inertia/Vue.
  • SQUIRE nextThe equipment manager: release script and local parity for Citadel.
  • DRAWBRIDGE nextThe Citadel sign-in gateway, lowered on request.
  • BOOSTER nextThe booster club: upgrade recipes, PHP 7 to 8 and Laravel N to N+1.
  • ROLLCALL nextEvery app answers “present”, with its PHP version.