YOUR DIGITAL HUB
← Back to blog

OWASP Top 10 (2025): concrete implementation with Symfony 7

· 10 min read
Cover visual — OWASP Top 10 2025 and Symfony 7

OWASP 2025 ten categories recap

The OWASP Foundation released the 2025 version of its Top 10 in January 2025, the first major revision since 2021. Major trends observed across 800+ applications audited during the year: rise of supply-chain and configuration issues, and persistence of the classic authorization and cryptography problems.

Rank Code Category
1 A01:2025 Broken Access Control
2 A02:2025 Cryptographic Failures
3 A03:2025 Injection
4 A04:2025 Insecure Design
5 A05:2025 Security Misconfiguration
6 A06:2025 Vulnerable and Outdated Components
7 A07:2025 Identification and Authentication Failures
8 A08:2025 Software and Data Integrity Failures
9 A09:2025 Security Logging and Monitoring Failures
10 A10:2025 Server-Side Request Forgery (SSRF)

This article dives into the concrete implementation of the seven categories that most directly impact a Symfony 7 application, with code you can apply immediately.

A01:2025 — Broken Access Control

Top of the top. In our 2025 audits, 73% of PHP applications exhibit at least one authorization vulnerability. The most frequent pattern: a controller that checks the user is authenticated but not that they own the requested resource.

The Symfony solution: Voters. Never write if ($user->getId() === $resource->getOwnerId()) directly in a controller. Always go through the abstraction, unit-testable, reusable.

<?php
declare(strict_types=1);

namespace App\Security\Voter;

use App\Entity\Invoice;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

final class InvoiceVoter extends Voter
{
    public const VIEW = 'INVOICE_VIEW';
    public const EDIT = 'INVOICE_EDIT';

    public function __construct(private readonly Security $security) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::VIEW, self::EDIT], true)
            && $subject instanceof Invoice;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
    ): bool {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;
        }

        /** @var Invoice $invoice */
        $invoice = $subject;

        return match ($attribute) {
            self::VIEW => $invoice->getTenantId() === $user->getTenantId(),
            self::EDIT => $invoice->getTenantId() === $user->getTenantId()
                && $this->security->isGranted('ROLE_ACCOUNTANT'),
            default => false,
        };
    }
}

In the controller:

#[Route('/invoices/{id}', name: 'invoice_show')]
public function show(Invoice $invoice): Response
{
    $this->denyAccessUnlessGranted(InvoiceVoter::VIEW, $invoice);
    return $this->render('invoice/show.html.twig', ['invoice' => $invoice]);
}

Golden rule: a controller never does manual authorization checks. Always via denyAccessUnlessGranted or the #[IsGranted] attribute.

A02:2025 — Cryptographic Failures

The most frequent cryptographic mistakes: MD5 or SHA-1 for passwords, keys hardcoded, TLS 1.0 or 1.1 still enabled.

For passwords in Symfony 7, the default is argon2id, which resists GPU and ASIC attacks best. Configuration:

# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
            algorithm: argon2id
            memory_cost: 65536   # 64 MiB
            time_cost: 4
            threads: 4

To encrypt application-level data (OAuth tokens to store, sensitive non-indexable PII), never reinvent. Sodium is bundled in PHP 7.2+ and gives XChaCha20-Poly1305 out-of-the-box:

<?php
declare(strict_types=1);

namespace App\Security;

final class SecretVault
{
    public function __construct(
        #[\SensitiveParameter] private readonly string $masterKey,
    ) {
        if (strlen($masterKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
            throw new \RuntimeException('Master key must be exactly 32 bytes.');
        }
    }

    public function seal(#[\SensitiveParameter] string $plaintext): string
    {
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher = sodium_crypto_secretbox($plaintext, $nonce, $this->masterKey);
        return base64_encode($nonce . $cipher);
    }

    public function open(string $sealed): string
    {
        $raw = base64_decode($sealed, strict: true);
        if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + 1) {
            throw new \RuntimeException('Invalid sealed value.');
        }
        $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $clear = sodium_crypto_secretbox_open($cipher, $nonce, $this->masterKey);
        if ($clear === false) {
            throw new \RuntimeException('Decryption failed.');
        }
        return $clear;
    }
}

The master key comes from Vault or AWS Secrets Manager, never from a committed .env. On the TLS side: TLS 1.2 minimum (1.3 recommended), OCSP stapling enabled, HSTS preload, no cipher suite below 128 bits.

A03:2025 — Injection

SQL, LDAP, template, OS, NoSQL. Symfony natively protects against most through Doctrine DBAL, but teams break the protection by building SQL by hand.

Bad: direct concatenation.

// NEVER DO THIS
$orderBy = $request->query->get('sort');
$sql = "SELECT * FROM invoice ORDER BY {$orderBy}";
$conn->executeQuery($sql);

Good: strict whitelist of the column name, bound parameters for values.

<?php
declare(strict_types=1);

use Doctrine\DBAL\Connection;

final class InvoiceRepository
{
    private const SORTABLE = ['created_at', 'total', 'customer_name'];

    public function __construct(private readonly Connection $conn) {}

    /**
     * @return list<array<string, scalar|null>>
     */
    public function listForTenant(int $tenantId, string $sort = 'created_at'): array
    {
        if (!in_array($sort, self::SORTABLE, true)) {
            $sort = 'created_at';
        }
        $qb = $this->conn->createQueryBuilder()
            ->select('*')
            ->from('invoice')
            ->where('tenant_id = :tenant')
            ->orderBy($sort, 'DESC')
            ->setMaxResults(100)
            ->setParameter('tenant', $tenantId, \PDO::PARAM_INT);
        return $qb->fetchAllAssociative();
    }
}

Three operational rules:

  1. Values always go through bound parameters. Never concatenation, even for a supposedly safe integer.
  2. Identifiers (columns, tables, ORDER BY) go through a whitelist. DBAL does not protect them, application code must validate.
  3. Turn on strict PHPStan SQL via staabm/phpstan-dba to type query results and detect injection paths.

A05:2025 — Security Misconfiguration

The sneakiest one. Missing headers, debug enabled in prod, CORS too permissive, overly broad file permissions.

Our Symfony 7 baseline for security headers goes through NelmioSecurityBundle:

# config/packages/nelmio_security.yaml
nelmio_security:
    csp:
        enabled: true
        hosts: []
        content_types: []
        enforce:
            level1_fallback: false
            browser_adaptive: { enabled: true }
            report-uri: '%env(CSP_REPORT_URI)%'
            default-src: ["'self'"]
            script-src: ["'self'", "'strict-dynamic'", "'nonce-{nonce}'"]
            style-src: ["'self'", "'nonce-{nonce}'"]
            img-src: ["'self'", 'data:', 'https:']
            font-src: ["'self'"]
            connect-src: ["'self'"]
            frame-ancestors: ["'none'"]
            base-uri: ["'self'"]
            object-src: ["'none'"]
            form-action: ["'self'"]
            upgrade-insecure-requests: true
    clickjacking:
        paths: { '^/': DENY }
    referrer_policy:
        enabled: true
        policies: [strict-origin-when-cross-origin]
    xss_protection:
        enabled: true
        mode_block: true
    content_type:
        nosniff: true
    forced_ssl:
        enabled: true
        hsts_max_age: 63072000
        hsts_subdomains: true
        hsts_preload: true

Side checklist:

  • APP_DEBUG=0 and APP_ENV=prod in production, verified by a healthcheck that fails if debug is on.
  • debug_mode disabled on Profiler and Web Debug Toolbar, stripped from the prod build.
  • .env.local uncommitted, .env contains only placeholders.
  • Linux permissions: /var/www in 755, files in 644, never 777.
  • open_basedir configured to restrict PHP filesystem access.
  • Nginx or Apache hide versions (server_tokens off).

A07:2025 — Identification and Authentication Failures

Login brute force, no MFA, sessions that never regenerate.

Symfony 7 lets you configure a rate limiter on the login firewall:

# config/packages/security.yaml
security:
    firewalls:
        main:
            login_throttling:
                max_attempts: 5
                interval: '15 minutes'

Throttling defaults to IP + username. On an API, this blocks brute force but not distributed credential stuffing. Combine with a ReCAPTCHA v3 score or hCaptcha challenge above 3 failures in the same session.

For MFA, Scheb 2FA bundle is the Symfony reference:

# config/packages/scheb_2fa.yaml
scheb_two_factor:
    security_tokens:
        - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
    totp:
        enabled: true
        issuer: 'YOUR DIGITAL HUB'
        digits: 6
        window: 1
    email:
        enabled: false
    trusted_device:
        enabled: true
        lifetime: 5184000   # 60 days

Symfony session rules:

  • Session ID regenerated on authentication (enabled by default).
  • Session cookie: Secure, HttpOnly, SameSite=Lax (or Strict depending on flow).
  • Reasonable inactivity timeout: 30 to 60 minutes for business, 15 minutes for sensitive apps.
  • A server-side logout invalidates the token on the Symfony side AND on the store side.

A09:2025 — Security Logging and Monitoring Failures

No logs, no detection. In our audits, 40% of applications have no dedicated security event log.

Our Monolog dedicated security channel configuration:

# config/packages/monolog.yaml
monolog:
    channels: ['security']
    handlers:
        security_file:
            type: rotating_file
            path: '%kernel.logs_dir%/security.log'
            max_files: 90
            level: info
            channels: [security]
            formatter: monolog.formatter.json
        security_syslog:
            type: syslog
            level: warning
            channels: [security]
            ident: ydh-security
            facility: auth

Events to log systematically:

  • Successful and failed login, with IP, user-agent, user ID.
  • Password, MFA, email change.
  • Voter-denied access on a sensitive resource.
  • Detected privilege escalation attempt.
  • Role or permission change.
  • Personal data export (for GDPR auditability).
  • Administrative session invalidation.

Centralized listener:

<?php
declare(strict_types=1);

namespace App\Security\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;

final class AuthLogger
{
    public function __construct(
        private readonly LoggerInterface $securityLogger,
        private readonly RequestStack $requestStack,
    ) {}

    #[AsEventListener]
    public function onLoginSuccess(LoginSuccessEvent $event): void
    {
        $request = $this->requestStack->getMainRequest();
        $this->securityLogger->info('login.success', [
            'user' => $event->getUser()->getUserIdentifier(),
            'ip' => $request?->getClientIp(),
            'ua' => substr((string) $request?->headers->get('User-Agent'), 0, 200),
        ]);
    }

    #[AsEventListener]
    public function onLoginFailure(LoginFailureEvent $event): void
    {
        $request = $this->requestStack->getMainRequest();
        $this->securityLogger->warning('login.failure', [
            'reason' => $event->getException()->getMessageKey(),
            'ip' => $request?->getClientIp(),
            'ua' => substr((string) $request?->headers->get('User-Agent'), 0, 200),
        ]);
    }
}

The JSON files are ingested by Wazuh or Elastic Security for correlation and alerting.

A10:2025 — Server-Side Request Forgery

SSRF on Symfony happens when a controller accepts a user URL and fetches it server-side: avatar import, link preview, generic webhook.

Symfony HttpClient exposes a resolve option and a middleware to filter hosts:

<?php
declare(strict_types=1);

namespace App\Http;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class SafeHttpClient
{
    /**
     * @param list<string> $deniedCidrs
     */
    public function __construct(
        private readonly HttpClientInterface $client,
        private readonly array $deniedCidrs = [
            '10.0.0.0/8',
            '172.16.0.0/12',
            '192.168.0.0/16',
            '127.0.0.0/8',
            '169.254.0.0/16',
            '::1/128',
            'fc00::/7',
        ],
    ) {}

    public function fetch(string $url): ResponseInterface
    {
        $parsed = parse_url($url);
        if ($parsed === false || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
            throw new \InvalidArgumentException('Only http(s) URLs are allowed.');
        }
        $host = $parsed['host'] ?? '';
        $ip = gethostbyname($host);
        if ($ip === $host) {
            throw new \RuntimeException('DNS resolution failed.');
        }
        foreach ($this->deniedCidrs as $cidr) {
            if ($this->ipInCidr($ip, $cidr)) {
                throw new \RuntimeException('Internal host blocked by SSRF protection.');
            }
        }
        return $this->client->request('GET', $url, [
            'timeout' => 5.0,
            'max_redirects' => 2,
            'max_duration' => 10.0,
            'headers' => ['Accept' => 'text/html, image/*'],
            'extra' => ['curl' => [\CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS]],
        ]);
    }

    private function ipInCidr(string $ip, string $cidr): bool
    {
        // Simplified, see symfony/http-foundation IpUtils::checkIp
        return \Symfony\Component\HttpFoundation\IpUtils::checkIp($ip, $cidr);
    }
}

Two complementary rules:

  1. When possible, use a dedicated egress proxy (Smokescreen, Kubernetes egress-gateway) centralizing filtering.
  2. Treat any user URL as hostile, even behind a "trust this" button.

Analysis tools to wire into CI

Our mandatory CI security stack on GitHub Actions:

  • composer audit on every build: fails if a dependency has a known CVE.
  • Psalm TaintAnalysis (--taint-analysis) to track untrusted sources (HTTP request, uploaded file) down to sinks (SQL, shell, header).
  • Snyk or Semgrep for application + dependency scanning with security rulesets.
  • Trivy or Grype for Docker image scanning at build time.
  • OWASP ZAP as a smoke test in a pre-prod environment, 5-minute baseline scan on every release.
# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3' }
      - run: composer install --no-progress --prefer-dist
      - run: composer audit --locked
      - run: vendor/bin/psalm --taint-analysis --no-cache
      - uses: aquasecurity/trivy-action@master
        with:
          scan-type: fs
          severity: CRITICAL,HIGH
          exit-code: 1

Pre-production checklist

The checklist our boutique requires before every Symfony project goes live.

Category Control Expected state
A01 All controllers protected by Voter or #[IsGranted] PASS
A01 Automated test of forbidden cross-tenant access PASS
A02 argon2id on passwords, memory_cost >= 65536 PASS
A02 TLS 1.3 enabled, no cipher suite < 128 bits PASS
A03 No concatenated SQL, whitelist on sort columns PASS
A04 STRIDE threat modeling documented and validated PASS
A05 NelmioSecurityBundle configured, level 3 CSP tested PASS
A05 APP_DEBUG=0, Profiler stripped from prod build PASS
A06 composer audit green in CI, Dependabot or Renovate active PASS
A06 Docker images scanned (Trivy), no CRITICAL PASS
A07 login_throttling configured, MFA on admin accounts PASS
A08 Composer package signatures verified (composer config --global secure-http true) PASS
A09 Monolog security channel active, logs ingested by SIEM PASS
A10 All outbound HTTP endpoints use SafeHttpClient or a filtering proxy PASS

If any line is FAIL, no go-live.

Conclusion

The OWASP Top 10 2025 does not reinvent anything, it documents what serious teams already apply. The good news: Symfony 7 natively provides high-level primitives covering 80% of the categories. The bad news: most audits still surface forgotten Voters, missing logs and outdated dependencies.

For a full application security audit on your PHP stack, including grey-box pentest and a prioritized remediation plan, write to contact@your-digital-hub.com or discover our Cybersecurity expertise.