OWASP Top 10 (2025): concrete implementation with 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:
- Values always go through bound parameters. Never concatenation, even for a supposedly safe integer.
- Identifiers (columns, tables, ORDER BY) go through a whitelist. DBAL does not protect them, application code must validate.
- Turn on strict PHPStan SQL via
staabm/phpstan-dbato 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=0andAPP_ENV=prodin production, verified by a healthcheck that fails if debug is on.debug_modedisabled on Profiler and Web Debug Toolbar, stripped from the prod build..env.localuncommitted,.envcontains only placeholders.- Linux permissions:
/var/wwwin755, files in644, never777. open_basedirconfigured 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(orStrictdepending 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:
- When possible, use a dedicated egress proxy (Smokescreen, Kubernetes egress-gateway) centralizing filtering.
- 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 auditon 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.