Symfony Security Checklist for Heroku

Symfony has comprehensive security features built-in - configure them properly for production deployment on Heroku.

Expedited WAF for Symfony

Expedited WAF adds network level request filtering, preventing malicious attacks from ever touching your application. This enables true defense in depth for your Symfony app—even if your framework has a vulnerability or is improperly configured, Expedited WAF dynamically blocks threats before they reach your code.

From experience, we’ve seen many applications where attacks technically didn’t succeed, but burned through so many server resources that the application went down anyway. By filtering malicious requests at the network edge, Expedited WAF protects both your security and your availability.

Expedited WAF protects your Symfony app on Heroku

New Security Features for Your Symfony App

These capabilities aren’t available out of the box with Symfony, but Expedited WAF adds them instantly:

Symfony Security Component

  • Configure security.yaml

    # config/packages/security.yaml
    security:
        password_hashers:
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    
        providers:
            app_user_provider:
                entity:
                    class: App\Entity\User
                    property: email
    
        firewalls:
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
            main:
                lazy: true
                provider: app_user_provider
                form_login:
                    login_path: app_login
                    check_path: app_login
                logout:
                    path: app_logout
    
        access_control:
            - { path: ^/admin, roles: ROLE_ADMIN }
            - { path: ^/profile, roles: ROLE_USER }
    
  • Use proper password hashing

    // src/Controller/RegistrationController.php
    use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
    
    public function register(UserPasswordHasherInterface $passwordHasher)
    {
        $hashedPassword = $passwordHasher->hashPassword(
            $user,
            $plainPassword
        );
        $user->setPassword($hashedPassword);
    }
    
  • Implement password strength requirements

    // src/Entity/User.php
    use Symfony\Component\Validator\Constraints as Assert;
    
    class User
    {
        #[Assert\NotBlank]
        #[Assert\Length(min: 12)]
        #[Assert\Regex(
            pattern: "/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/",
            message: "Password must contain uppercase, lowercase, and number"
        )]
        private $password;
    }
    

Environment & Secrets

  • Use environment variables

    # config/packages/doctrine.yaml
    doctrine:
        dbal:
            url: '%env(DATABASE_URL)%'
    
  • Store secrets in Heroku config

    heroku config:set APP_SECRET='...'
    heroku config:set DATABASE_URL='...'
    
  • Set APP_ENV to prod

    heroku config:set APP_ENV=prod
    heroku config:set APP_DEBUG=0
    
  • Use Symfony secrets management

    php bin/console secrets:set API_KEY
    

CSRF Protection

  • Enable CSRF protection

    # config/packages/framework.yaml
    framework:
        csrf_protection: ~
    
  • Use CSRF tokens in forms

    {{ form_start(form) }}
        {{ form_widget(form) }}
        <button type="submit">Submit</button>
    {{ form_end(form) }}
    
  • Validate CSRF for AJAX requests

    fetch('/api/endpoint', {
        method: 'POST',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
        }
    });
    

Input Validation

  • Use Symfony Validator

    use Symfony\Component\Validator\Constraints as Assert;
    
    class UserDTO
    {
        #[Assert\NotBlank]
        #[Assert\Email]
        private $email;
    
        #[Assert\NotBlank]
        #[Assert\Length(min: 12)]
        private $password;
    }
    
    // In controller
    public function create(ValidatorInterface $validator, UserDTO $dto)
    {
        $errors = $validator->validate($dto);
        if (count($errors) > 0) {
            return new Response((string) $errors, 400);
        }
    }
    
  • Sanitize output in Twig

    {# Auto-escaped by default #}
    {{ user.name }}
    
    {# Only use raw when absolutely necessary #}
    {{ content|raw }}
    

Database Security (Doctrine)

  • Configure Heroku Postgres

    # config/packages/doctrine.yaml
    doctrine:
        dbal:
            url: '%env(resolve:DATABASE_URL)%'
            server_version: '13'
            charset: utf8mb4
    
  • Use parameterized queries

    // Good - parameterized
    $user = $entityManager->getRepository(User::class)
        ->findOneBy(['email' => $email]);
    
    // Bad - SQL injection risk
    $query = $entityManager->createQuery(
        "SELECT u FROM App\Entity\User u WHERE u.email = '$email'"
    );
    
  • Use DQL or QueryBuilder

    $qb = $entityManager->createQueryBuilder();
    $qb->select('u')
       ->from('App\Entity\User', 'u')
       ->where('u.email = :email')
       ->setParameter('email', $email);
    
  • Don’t log SQL queries in production

    # config/packages/dev/doctrine.yaml only
    doctrine:
        dbal:
            logging: true
            profiling: true
    

Security Headers

  • Configure security headers

    # config/packages/framework.yaml
    framework:
        http_client:
            default_options:
                headers:
                    'X-Frame-Options': 'DENY'
                    'X-Content-Type-Options': 'nosniff'
                    'X-XSS-Protection': '1; mode=block'
    

    Or use NelmioSecurityBundle:

    composer require nelmio/security-bundle
    
    # config/packages/nelmio_security.yaml
    nelmio_security:
        forced_ssl:
            enabled: true
        clickjacking:
            paths:
                '^/.*': DENY
        content_type:
            nosniff: true
        xss_protection:
            enabled: true
            mode_block: true
    

Session Security

  • Configure secure sessions
    # config/packages/framework.yaml
    framework:
        session:
            handler_id: ~
            cookie_secure: auto
            cookie_httponly: true
            cookie_samesite: lax
            gc_maxlifetime: 3600
    

API Security

  • Implement API authentication

    # config/packages/security.yaml
    security:
        firewalls:
            api:
                pattern: ^/api
                stateless: true
                jwt: ~  # Using LexikJWTAuthenticationBundle
    
  • Use JWTs for API authentication

    composer require lexik/jwt-authentication-bundle
    
    # config/packages/lexik_jwt_authentication.yaml
    lexik_jwt_authentication:
        secret_key: '%env(resolve:JWT_SECRET_KEY)%'
        public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
        pass_phrase: '%env(JWT_PASSPHRASE)%'
        token_ttl: 3600
    
  • Implement rate limiting

    composer require symfony/rate-limiter
    
    use Symfony\Component\RateLimiter\RateLimiterFactory;
    
    #[Route('/api/login', methods: ['POST'])]
    public function login(RateLimiterFactory $loginLimiter)
    {
        $limiter = $loginLimiter->create($request->getClientIp());
    
        if (false === $limiter->consume(1)->isAccepted()) {
            return new Response('Too many requests', 429);
        }
    }
    

CORS Configuration

  • Configure CORS

    composer require nelmio/cors-bundle
    
    # config/packages/nelmio_cors.yaml
    nelmio_cors:
        defaults:
            origin_regex: true
            allow_origin: ['https://yourdomain.com']
            allow_methods: ['GET', 'POST']
            allow_headers: ['Content-Type', 'Authorization']
            max_age: 3600
        paths:
            '^/api/':
                allow_origin: ['https://yourdomain.com']
    

File Uploads

  • Validate file uploads

    use Symfony\Component\Validator\Constraints as Assert;
    
    class UploadDTO
    {
        #[Assert\File(
            maxSize: '5M',
            mimeTypes: ['image/jpeg', 'image/png'],
            mimeTypesMessage: 'Please upload a valid image'
        )]
        private $file;
    }
    
  • Store uploads on S3

    composer require league/flysystem-aws-s3-v3
    
    # config/packages/oneup_flysystem.yaml
    oneup_flysystem:
        adapters:
            s3_adapter:
                awss3v3:
                    client: aws_s3_client
                    bucket: '%env(S3_BUCKET)%'
        filesystems:
            s3_filesystem:
                adapter: s3_adapter
    

Error Handling

  • Disable debug mode in production

    heroku config:set APP_DEBUG=0
    
  • Configure custom error pages

    # config/packages/framework.yaml
    framework:
        error_controller: App\Controller\ErrorController::show
    
  • Don’t expose stack traces

    • Set APP_DEBUG=0
    • Custom error controller

Logging

  • Configure production logging

    # config/packages/prod/monolog.yaml
    monolog:
        handlers:
            main:
                type: stream
                path: php://stderr
                level: error
    
  • Don’t log sensitive data

    • Filter passwords, tokens in logs
    • Use processors to sanitize

Composer Dependencies

  • Audit dependencies

    composer audit
    composer update
    
  • Keep Symfony updated

    composer recipes:update
    
  • Use composer.lock

    • Commit to version control
    • Ensures consistent builds

Heroku Deployment

  • Create Procfile

    web: vendor/bin/heroku-php-apache2 public/
    
  • Configure buildpack

    heroku buildpacks:set heroku/php
    
  • Set environment for production

    heroku config:set APP_ENV=prod
    heroku config:set APP_DEBUG=0
    
  • Run migrations on deploy

    heroku run php bin/console doctrine:migrations:migrate --no-interaction
    
  • Clear cache after deployment

    heroku run php bin/console cache:clear
    

Performance & Caching

  • Enable OpCache

    # php.ini
    opcache.enable=1
    opcache.memory_consumption=256
    opcache.max_accelerated_files=20000
    
  • Configure Symfony cache

    # config/packages/framework.yaml
    framework:
        cache:
            app: cache.adapter.redis
            default_redis_provider: '%env(REDIS_URL)%'
    

Testing

  • Write security tests

    use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
    
    class SecurityTest extends WebTestCase
    {
        public function testPageIsSecure()
        {
            $client = static::createClient();
            $client->request('GET', '/admin');
    
            $this->assertEquals(302, $client->getResponse()->getStatusCode());
        }
    }
    
  • Test on Heroku staging

    heroku create myapp-staging
    git push staging main
    

Additional Resources