PHP Security

The complete PHP security checklist: XSS, CSRF, SQL injection, password hashing, session hijacking, file upload threats, secure headers, secrets management, and a production checklist.

Advanced 12 min read 12 examples

Security Mindset

Two principles drive every defense:

  1. Never trust input. User input, API responses, file contents - validate everything at the boundary.
  2. Escape per context. Same data needs different escaping for HTML, URL, JS, SQL.

SQL Injection

PHP
<?php
// VULNERABLE
$pdo->query("SELECT * FROM users WHERE id = " . $_GET["id"]);

// SAFE - prepared statements
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET["id"]]);

// Identifiers cant be placeholders - validate against allow-list
$allowed = ["name", "email", "created_at"];
$sort    = in_array($_GET["sort"] ?? "", $allowed, true) ? $_GET["sort"] : "id";
$pdo->query("SELECT * FROM users ORDER BY $sort");

Detailed: Prepared Statements tutorial.

XSS - Cross-Site Scripting

Untrusted data echoed into HTML lets attackers inject scripts that run in your users\' browsers - stealing cookies, taking over accounts, defacing pages.

PHP
<?php
// VULNERABLE
echo "<h1>Hello $name</h1>";

// SAFE - HTML context
echo "<h1>Hello " . htmlspecialchars($name, ENT_QUOTES, "UTF-8") . "</h1>";

// HTML attribute - same plus ENT_QUOTES (default for above)
echo '<input value="' . htmlspecialchars($value, ENT_QUOTES) . '">';

// JavaScript string context - encode with json_encode
echo "<script>const user = " . json_encode($name,
    JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";</script>";

// URL context
echo "<a href=\"/search?q=" . urlencode($q) . "\">";
Add a Content Security Policy

CSP is a browser-enforced last line of defense. Content-Security-Policy: default-src 'self'; blocks inline scripts and external sources unless you opt in. Even if XSS slips through, CSP often blocks the payload.

CSRF - Cross-Site Request Forgery

An attacker tricks a logged-in user\'s browser into submitting requests to your site using its valid cookies. Defense: a per-session token your form includes that an attacker cannot guess.

PHP
<?php
session_start();

function csrf_token(): string {
    return $_SESSION["csrf"] ??= bin2hex(random_bytes(32));
}

function csrf_check(): void {
    if (!hash_equals($_SESSION["csrf"] ?? "", $_POST["csrf"] ?? "")) {
        http_response_code(403);
        exit("Invalid CSRF token");
    }
}

// On form display
?>
<form method="post">
    <input type="hidden" name="csrf" value="<?= csrf_token() ?>">
    ...
</form>

<?php
// On submission
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    csrf_check();
    // ... safe to process ...
}

For APIs that use only the SameSite=Lax cookie attribute (modern browsers), CSRF risk on top-level navigations is largely mitigated - but token-based defense is still recommended for state-changing requests.

Password Hashing

PHP
<?php
// Registration - hash the password
$hash = password_hash($plain, PASSWORD_BCRYPT);   // or PASSWORD_ARGON2ID
// Store $hash in the database (NOT $plain)

// Login - verify
if (password_verify($plain, $row["password_hash"])) {
    // Granted
    // Check if rehash needed (e.g., cost factor was upgraded)
    if (password_needs_rehash($row["password_hash"], PASSWORD_BCRYPT)) {
        $newHash = password_hash($plain, PASSWORD_BCRYPT);
        // Update the stored hash
    }
}
Never store plain passwords or use MD5/SHA1

If your DB leaks, hashed passwords slow attackers down dramatically. MD5/SHA1 are cracked in seconds with rainbow tables - bcrypt/Argon2id take years.

Session Security

  • Regenerate session ID on login: session_regenerate_id(true)
  • HttpOnly cookies - JS cannot read the session cookie
  • Secure cookies - sent only over HTTPS
  • SameSite=Lax - CSRF protection by default
  • Short lifetime for sensitive admin sessions
PHP
<?php
session_start([
    "cookie_secure"   => true,
    "cookie_httponly" => true,
    "cookie_samesite" => "Lax",
    "use_strict_mode" => true,
]);

File Upload Security

PHP
<?php
$file = $_FILES["upload"] ?? null;
if (!$file || $file["error"] !== UPLOAD_ERR_OK) die("Upload failed");

// Size limit
if ($file["size"] > 5 * 1024 * 1024) die("Too large");

// MIME by CONTENT, not extension
$mime = mime_content_type($file["tmp_name"]);
$allowed = ["image/jpeg" => "jpg", "image/png" => "png"];
if (!isset($allowed[$mime])) die("Type not allowed");

// Generate random filename - never use user-supplied name
$name = bin2hex(random_bytes(16)) . "." . $allowed[$mime];

// Store OUTSIDE the web root if possible, or in a folder with PHP execution disabled
$dest = __DIR__ . "/../private-uploads/$name";

if (!move_uploaded_file($file["tmp_name"], $dest)) die("Save failed");

Place this in any upload folder\'s .htaccess to neutralize uploaded executables:

Apacheuploads/.htaccess
php_flag engine off
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh)$">
    Require all denied
</FilesMatch>

Secure Headers

PHP
<?php
header("X-Content-Type-Options: nosniff");
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("Permissions-Policy: camera=(), microphone=(), geolocation=()");
header("Content-Security-Policy: default-src 'self'; script-src 'self'");

// Once HTTPS is verified working
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");

Environment & Secrets

  • Never commit secrets to git. Use .env files loaded with phpdotenv.
  • Add .env to .gitignore and to .htaccess deny rules.
  • Read secrets from environment variables at runtime - never hardcode.
  • Use a secrets manager for production (AWS Secrets Manager, HashiCorp Vault).

Supply Chain (composer audit)

Bash
# Check for known vulnerabilities in dependencies
composer audit

# Also in CI - fail the build on findings
composer audit --format=json | jq '.vulnerabilities | length'

# Keep deps up to date
composer outdated --direct

Production Checklist

  • [ ] Running supported PHP version with latest security patches
  • [ ] display_errors = Off in production php.ini
  • [ ] HTTPS enforced (HSTS header)
  • [ ] All forms have CSRF tokens
  • [ ] All output through htmlspecialchars() (or templating engine)
  • [ ] All SQL uses prepared statements
  • [ ] Passwords stored with password_hash()
  • [ ] Session cookies: secure, httponly, samesite
  • [ ] Secure headers set (CSP, X-Frame-Options, etc.)
  • [ ] Uploads validated by MIME content, stored with random names
  • [ ] Secrets in environment variables, not in code
  • [ ] composer audit clean
  • [ ] Rate limiting on auth endpoints
  • [ ] Web server hardened (no directory listings, no exposed .git/.env)
  • [ ] Logging and monitoring in place

Next Steps

Frequently Asked Questions

No. Modern PHP (8.x) is as secure as any major language - the security issues in older PHP apps stem from old practices and missing defenses, not the language itself. Apply the principles in this guide and you're on solid ground.

Always password_hash(). It uses bcrypt (or Argon2id with explicit choice), generates a unique salt per password, and updates to better algorithms over time. Custom hashing - even with salt - is almost always weaker.

XSS by far - careless output of user data into HTML. SQL injection comes second (usually in legacy code without prepared statements). File upload vulnerabilities and weak session handling round out the top.

A web application firewall is defense-in-depth, not a substitute for secure code. ModSecurity (OWASP Core Rule Set) or Cloudflare's WAF catches some attacks but lets others through. Write secure code first; add the WAF as an extra layer.

Run the latest stable PHP version, apply security patches promptly, set display_errors=Off in production, disable risky ini settings (allow_url_include=Off), and audit dependencies regularly with composer audit.