Hardening a Symfony App

Trusted proxies and hosts, forced HTTPS, response headers, login throttling and a routine dependency audit for a production Symfony app.

Syntaxframework: { trusted_proxies: '%env(TRUSTED_PROXIES)%', trusted_hosts: ['^example\.com$'] }

Authentication and authorization decide who may do what. Hardening is the layer underneath: making sure the application is not lied to about the request, and is not cheap to grind against.

Trust the request only where it is earned

Behind a load balancer, the client's real address and scheme arrive in X-Forwarded-* headers. Symfony ignores them until you set framework.trusted_proxies to the proxy's addresses and trusted_headers to the headers it actually sets. Keep that list tight. Trusting every source lets any client forge its own IP, which quietly defeats IP allow-lists and per-IP rate limits. trusted_hosts is the matching control for the Host header: a list of regular expressions, where a request whose host matches none is answered with 400.

HTTPS and headers

Force the scheme with requires_channel: https on an access_control rule. Response headers are not configuration; set them once in a listener on the response event. X-Content-Type-Options: nosniff, Referrer-Policy, X-Frame-Options and Strict-Transport-Security cost nothing and close off whole classes of attack.

Throttling and dependencies

login_throttling on the firewall limits login attempts, by default five per minute, keyed on the submitted identifier as well as the IP. It needs symfony/rate-limiter. Symfony 8.1 also added a #[RateLimit] attribute for limiting any controller, but login keeps its own feature precisely because of that identifier keying.

Finally, composer audit reports installed packages with known advisories. Run it in the pipeline, not when you remember.

Example

Example · php
<?php
// src/EventListener/SecurityHeadersListener.php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::RESPONSE)]
final class SecurityHeadersListener
{
    public function __construct(private readonly bool $enableHsts)
    {
    }

    public function __invoke(ResponseEvent $event): void
    {
        // sub-requests have their own responses, which are never sent to the browser
        if (!$event->isMainRequest()) {
            return;
        }

        $headers = $event->getResponse()->headers;
        $headers->set('X-Content-Type-Options', 'nosniff');
        $headers->set('X-Frame-Options', 'DENY');
        $headers->set('Referrer-Policy', 'same-origin');

        // HSTS tells browsers never to use http for this host again, which is
        // why it stays switched off outside production
        if ($this->enableHsts) {
            $headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }
    }
}

// Every page response now carries the three static headers, and in production
// the HSTS header as well.

When to use it

  • Get the real client IP into logs and rate limits for an app behind a load balancer.
  • Stop password-reset emails from containing links to an attacker-supplied host.
  • Limit login attempts without locking out everyone sharing one office IP.
  • Fail the build when a dependency has a published advisory.

More examples

Proxies, hosts and HTTPS

Set these before anything that depends on the client address or the scheme: getClientIp(), isSecure(), generated absolute URLs, per-IP rate limits. The trusted_hosts list also stops Host-header poisoning of generated URLs, which is how a password-reset link ends up pointing at someone else's domain.

Example · yaml
# config/packages/framework.yaml
framework:
    # longhand: the exact addresses or CIDR ranges of your proxies
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    # shorthand for every private range, for a proxy on the same private network:
    # trusted_proxies: 'private_ranges'

    # only the headers your proxy really sets
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']

    # regular expressions; a request with any other Host is answered with 400
    trusted_hosts:
        - '^(www\.)?example\.com$'

# .env
# TRUSTED_PROXIES=10.0.0.0/8

Throttling the login route

Throttling by IP alone punishes everyone behind one office connection; throttling by username alone lets an attacker spread attempts across accounts. The built-in limiter uses both keys, which is the reason to prefer it over a hand-written limit on the login controller.

Example · yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            # shorthand: 5 attempts per minute, keyed on identifier and IP
            login_throttling: null

            # the same feature with explicit limits
            # login_throttling:
            #     max_attempts: 3
            #     interval: '15 minutes'

    access_control:
        # redirect every plain-http request to https
        - { path: '^/', roles: PUBLIC_ACCESS, requires_channel: https }

# requires: composer require symfony/rate-limiter

The dependency audit as routine

composer audit checks the versions pinned in composer.lock against published advisories, and reports abandoned packages as well. Wire it into the pipeline beside the test suite: an audit you run by hand is an audit that runs the week after the advisory mattered.

Example · bash
# run it locally, and again in CI so it cannot be skipped
$ composer audit

# Found 1 security vulnerability advisory affecting 1 package:
# +-------------------+--------------------------------------------+
# | Package           | example/library                            |
# | Severity          | high                                       |
# | Title             | Example advisory title                     |
# | Affected versions | >=2.0.0,<2.0.7                             |
# | Reported at       | 2026-01-15T00:00:00+00:00                  |
# +-------------------+--------------------------------------------+

# machine-readable, for a pipeline step that should fail the build
$ composer audit --format=json

Discussion

  • Be the first to comment on this lesson.