Security

PHP Security in 2026: CSP, SAST, and Best Practices

Harden your PHP application with Content Security Policy, static analysis security testing, input validation, and modern authentication patterns.

PHP security checklist with CSP headers and SAST scanning

PHP security is not a separate discipline—it is a set of practices woven into how you write, test, and deploy code. The OWASP Top 10 has not changed dramatically in years because the fundamentals remain the same: validate input, encode output, use parameterized queries, and manage authentication properly. What has changed is the tooling available to enforce these practices automatically.

Content Security Policy (CSP)

CSP is an HTTP header that tells browsers which resources (scripts, styles, images, fonts) are allowed to load. It is the most effective defense against cross-site scripting (XSS) attacks.

Basic CSP for PHP applications

1
2
3
4
5
6
7
8
9
10
11
12
// Middleware or bootstrap
header("Content-Security-Policy: " . implode('; ', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", // Most PHP apps need inline styles
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
]));

Nonce-based CSP for inline scripts

If your PHP templates include inline <script> blocks, use nonces:

1
2
3
4
// Generate a unique nonce per request
$nonce = base64_encode(random_bytes(16));

header("Content-Security-Policy: script-src 'self' 'nonce-{$nonce}'");
1
2
3
4
5
<!-- In your template -->
<script nonce="<?= $nonce ?>">
// This inline script is allowed because the nonce matches
document.getElementById('form').addEventListener('submit', validate);
</script>

CSP report-only mode

Deploy CSP in report-only mode first to catch violations without breaking functionality:

1
header("Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report");
1
2
3
4
5
6
7
8
9
10
// /csp-report endpoint
$report = json_decode(file_get_contents('php://input'), true);
$violation = $report['csp-report'] ?? [];

error_log(sprintf(
'CSP Violation: directive=%s, blocked=%s, page=%s',
$violation['violated-directive'] ?? 'unknown',
$violation['blocked-uri'] ?? 'unknown',
$violation['document-uri'] ?? 'unknown'
));

Static Application Security Testing (SAST)

SAST tools analyze your source code without running it. They catch security issues before the code reaches production.

Psalm with security analysis

1
2
composer require --dev vimeo/psalm
vendor/bin/psalm --taint-analysis src/

Psalm’s taint analysis tracks data flow from user input to sensitive operations:

1
2
3
4
5
6
7
8
9
10
11
12
// Psalm catches this: tainted input reaches SQL query
function getUser(string $id): array
{
// Psalm flags: TaintedSql
return DB::select("SELECT * FROM users WHERE id = {$id}");
}

// Safe version
function getUser(string $id): array
{
return DB::select("SELECT * FROM users WHERE id = ?", [$id]);
}

PHPStan security rules

1
composer require --dev phpstan/phpstan-strict-rules
1
2
3
4
5
6
# phpstan.neon
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
level: 8

PHPStan catches type-related security issues: loose comparisons, missing null checks, and type juggling vulnerabilities.

Snyk and Semgrep

For larger projects, dedicated SAST tools provide broader coverage:

1
2
3
4
5
# Snyk scans dependencies for known vulnerabilities
snyk test

# Semgrep scans code for security patterns
semgrep --config p/php src/

Input validation

Every piece of data from outside your application is untrusted: GET/POST parameters, headers, cookies, uploaded files, and API request bodies.

Validation patterns

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Laravel validation (preferred in Laravel projects)
$validated = $request->validate([
'email' => ['required', 'email:rfc,dns', 'max:255'],
'name' => ['required', 'string', 'max:100', 'regex:/^[\pL\s\-]+$/u'],
'age' => ['required', 'integer', 'min:13', 'max:150'],
'url' => ['nullable', 'url', 'max:2048'],
]);

// Framework-agnostic validation
function validateEmail(string $input): string
{
$email = filter_var($input, FILTER_VALIDATE_EMAIL);
if ($email === false) {
throw new ValidationException('Invalid email address');
}
if (strlen($email) > 255) {
throw new ValidationException('Email too long');
}
return $email;
}

Common validation mistakes

1
2
3
4
5
6
7
8
9
10
// BAD: trusting filter_var alone for URLs
$url = filter_var($input, FILTER_VALIDATE_URL);
// filter_var accepts javascript: URLs!

// GOOD: also check the scheme
$url = filter_var($input, FILTER_VALIDATE_URL);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https'], true)) {
throw new ValidationException('Invalid URL scheme');
}

Output encoding

Every variable rendered in HTML must be encoded:

1
2
3
4
5
6
7
8
9
// In Blade templates (auto-escaped)
{{ $userInput }} <!-- safe: htmlspecialchars applied -->
{!! $trustedHtml !!} <!-- dangerous: raw output -->

// In plain PHP
echo htmlspecialchars($userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

// In JSON responses
echo json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_THROW_ON_ERROR);

Context-specific encoding

Different contexts need different encoding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HTML context
echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// URL context
echo urlencode($input);

// JavaScript context (inside <script>)
echo json_encode($input, JSON_HEX_TAG | JSON_HEX_AMP);

// CSS context
echo preg_replace('/[^a-zA-Z0-9\-_]/', '', $input);

// SQL context — use prepared statements, never encoding
$stmt = $pdo->prepare('SELECT * FROM users WHERE name = ?');
$stmt->execute([$input]);

Authentication hardening

Password hashing

1
2
3
4
5
6
7
8
9
10
11
12
13
// Always use password_hash with the default algorithm
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verify
if (password_verify($input, $hash)) {
// Authenticated
}

// Check if the hash needs rehashing (algorithm upgrade)
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$newHash = password_hash($input, PASSWORD_DEFAULT);
// Update stored hash
}

PASSWORD_DEFAULT uses bcrypt currently and will automatically upgrade to stronger algorithms in future PHP versions. Never use md5(), sha1(), or sha256() for passwords.

Rate limiting authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
// Simple rate limiter using cache
function checkAuthRateLimit(string $ip): bool
{
$key = "auth_attempts:{$ip}";
$attempts = (int) cache()->get($key, 0);

if ($attempts >= 5) {
return false; // Rate limited
}

cache()->put($key, $attempts + 1, 300); // 5-minute window
return true;
}

Timing-safe comparison

1
2
3
4
5
// WRONG: vulnerable to timing attacks
if ($token === $storedToken) { ... }

// RIGHT: constant-time comparison
if (hash_equals($storedToken, $token)) { ... }

Security headers

1
2
3
4
5
6
// Complete security headers for a PHP application
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');

Common mistakes

Using == instead of === for security checks: PHP’s loose comparison causes type juggling. "0" == false is true. Always use strict comparison.

Storing secrets in version control: API keys, database passwords, and encryption keys belong in environment variables, not in .env files committed to git.

Not updating dependencies: Run composer audit regularly. Known vulnerabilities in dependencies are the lowest-effort attack vector.

1
2
# Check for known vulnerabilities in dependencies
composer audit

Trusting client-side validation: JavaScript validation is a UX improvement, not a security measure. Always re-validate on the server.

FAQ

Is PHP inherently less secure than other languages?

No. PHP’s security reputation comes from its low barrier to entry (many beginners write insecure code) and historical design choices (like register_globals, removed long ago). Modern PHP with frameworks like Laravel has security primitives that match any language.

Should I use a WAF (Web Application Firewall)?

A WAF adds defense-in-depth but does not replace secure code. Use Cloudflare WAF or ModSecurity as an additional layer, not as your primary defense.

How often should I run security scans?

Run SAST on every pull request. Run dependency audits weekly. Run penetration tests quarterly for high-value applications.

Next steps

Start with two actions: add composer audit to your CI pipeline and deploy CSP in report-only mode. These two steps catch the most common vulnerability categories with minimal effort.

For understanding how PHP handles data at the language level, the PDO prepared statements guide covers the database security fundamentals. The Docker guide covers container-level security hardening that complements application-level security.