DEV Community

Cover image for Essential Security Practices for Modern PHP Development
Senthil Kumar MS
Senthil Kumar MS

Posted on Originally published at phpscientist.com on

Essential Security Practices for Modern PHP Development

Securing a modern PHP application comes down to a few non-negotiables. Use PDO prepared statements to stop SQL injection, escape output with htmlspecialchars() to prevent XSS and protect every form with CSRF tokens. Harden php.ini and file permissions, and hash passwords with password\_hash() using Argon2id or bcrypt. This guide walks through each layer with copy-ready code, then gives you a checklist to audit an existing project. It assumes a supported PHP version; if you are still on an older release, read PHP 8.3 and beyond first.

Key takeaways

  • Never build SQL from strings: use PDO prepared statements with emulation turned off, and allow-list anything that can't be bound, such as column names.
  • Escape output for its context (HTML, attributes, JavaScript, URLs) and add a Content-Security-Policy as a safety net.
  • Protect every state-changing request with a CSRF token and SameSite cookies, and regenerate the session ID on login.
  • Hash passwords with password_hash() (Argon2id or bcrypt) and rehash old hashes automatically at login.
  • Harden php.ini, store uploads outside the web root, run composer audit in CI and stay on a supported PHP version.

Here is a breakdown of the critical security layers every PHP developer should implement to protect their applications and their reputation. Each section explains the threat, shows the fix, and lists the mistakes that still slip through code review.

Defeating the “Big Three” Vulnerabilities

SQL injection (SQLi)

Never, under any circumstances, concatenate user input directly into a query string. Use PDO (PHP Data Objects) with prepared statements. This separates the query logic from the data, so input can never change the meaning of the query. The OWASP SQL Injection Prevention Cheat Sheet covers the theory; here is the practice.

Quick answer: the best way to prevent SQL injection in PHP is PDO with prepared statements and bound parameters.

<?php
declare(strict_types=1);

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false, // real server-side prepared statements
]);

// Wrong: the input becomes part of the SQL
// $pdo->query("SELECT * FROM users WHERE email = '{$_GET['email']}'");

// Right: the input is sent separately as data
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = :email');
$stmt->execute(['email' => $_GET['email'] ?? '']);
$user = $stmt->fetch();
Enter fullscreen mode Exit fullscreen mode

Placeholders only work for values. Table names, column names and sort directions cannot be bound, so check them against a fixed allow-list:

<?php
$sortable = ['created_at', 'name', 'price'];
$column = in_array($_GET['sort'] ?? '', $sortable, true) ? $_GET['sort'] : 'created_at';
$direction = ($_GET['dir'] ?? '') === 'asc' ? 'ASC' : 'DESC';

$stmt = $pdo->prepare("SELECT id, name, price FROM products ORDER BY {$column} {$direction} LIMIT :limit");
$stmt->bindValue('limit', 20, PDO::PARAM_INT);
$stmt->execute();
Enter fullscreen mode Exit fullscreen mode

For IN (...) lists, generate one placeholder per value rather than imploding the values into the SQL.

Cross-site scripting (XSS)

Always assume “all input is evil.” When echoing data back to the browser, use htmlspecialchars() to convert special characters into HTML entities. This prevents attackers from injecting script tags that could steal session cookies or act as the user. A tiny helper makes the safe path the easy path:

<?php
function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}
?>
<h1><?= e($post['title']) ?></h1>
<a href="/search?q=<?= e(urlencode($query)) ?>" title="<?= e($post['title']) ?>">Search</a>

<script>
  // Data for JavaScript: encode as JSON, never paste raw strings into scripts
  const settings = <?= json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
</script>
Enter fullscreen mode Exit fullscreen mode

Escaping depends on context: HTML text, attributes, URLs and JavaScript each need their own encoding. Template engines such as Twig and Blade escape HTML by default, but {!! !!} and |raw switch that off, so search for them in code review. If you must accept HTML from users, for example in a comment editor, clean it with a dedicated sanitizer such as HTML Purifier or Symfony's HtmlSanitizer instead of writing your own regular expressions. The OWASP XSS Prevention Cheat Sheet lists the rules for every context.

Cross-site request forgery (CSRF)

Protect your forms with a secret token tied to the user's session. Validate it on every POST, PUT, PATCH and DELETE request to make sure the action was actually started from your site and not from a malicious third-party page. Frameworks do this for you; in plain PHP it takes a few lines:

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

function verify_csrf(): void
{
    $sent = $_POST['_csrf'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!is_string($sent) || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
        http_response_code(419);
        exit('Your session expired. Please reload the page and try again.');
    }
}

if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
    verify_csrf();
}
?>
<form method="post" action="/account/email">
  <input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
  ...
</form>
Enter fullscreen mode Exit fullscreen mode

hash\_equals() compares the strings in constant time, so the token cannot be guessed by timing responses. Combine tokens with SameSite cookies (covered below) and never change data on a GET request. See the OWASP CSRF Prevention Cheat Sheet for edge cases such as login forms and APIs.

Hardening the Environment

Security doesn’t stop at the code; it extends to your Nginx and PHP-FPM configuration. For a full walkthrough of performance and security settings, see basic PHP settings for secure, high-performance applications. These are the security-critical lines for production:

; php.ini (production)
expose_php = Off               ; don't advertise the PHP version in headers
display_errors = Off           ; never show stack traces to visitors
log_errors = On
error_log = /var/log/php/error.log
allow_url_include = Off        ; block remote file inclusion
allow_url_fopen = Off          ; enable only if your code needs remote reads
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
open_basedir = /var/www/app:/tmp
file_uploads = On
upload_max_filesize = 10M
post_max_size = 12M
Enter fullscreen mode Exit fullscreen mode

Check what your dependencies need before you disable functions; some packages call proc\_open() for legitimate work, such as queue workers. In that case, run those processes in a separate PHP-FPM pool or CLI configuration instead of opening them up for the whole web application.

File permissions matter just as much. Your web server user should only be able to write to the directories that need it, such as storage or uploads. Everything else should be read-only to PHP:

# Code owned by a deploy user, readable by the web server group
sudo chown -R deploy:www-data /var/www/app
sudo find /var/www/app -type d -exec chmod 750 {} \;
sudo find /var/www/app -type f -exec chmod 640 {} \;

# Only these directories are writable by PHP
sudo chmod -R 770 /var/www/app/storage /var/www/app/bootstrap/cache

# Secrets readable by the app, not by everyone
sudo chmod 640 /var/www/app/.env
Enter fullscreen mode Exit fullscreen mode

Point the web server's document root at the public directory only, so files like .env, composer.json and your source code can never be downloaded.

Modern Authentication and Password Hashing

Gone are the days of MD5 or SHA1. Use PHP’s native password_hash() functions with the Argon2id or bcrypt algorithm. They are deliberately slow and salted automatically, which makes brute-force and rainbow-table attacks far harder. The part most tutorials skip is rehashing: when you raise the cost or change algorithm, existing hashes upgrade the next time each user signs in.

<?php
const HASH_ALGO = PASSWORD_ARGON2ID; // or PASSWORD_BCRYPT if Argon2 isn't compiled in
const HASH_OPTIONS = [];             // defaults are sensible; tune memory_cost for Argon2id if needed

// Registration
$hash = password_hash($plainPassword, HASH_ALGO, HASH_OPTIONS);

// Login
function attempt_login(PDO $pdo, string $email, string $password): ?array
{
    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch();

    if (!$user || !password_verify($password, $user['password_hash'])) {
        return null; // same message for "no such user" and "wrong password"
    }

    if (password_needs_rehash($user['password_hash'], HASH_ALGO, HASH_OPTIONS)) {
        $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
            ->execute([password_hash($password, HASH_ALGO, HASH_OPTIONS), $user['id']]);
    }

    session_regenerate_id(true);
    $_SESSION['user_id'] = $user['id'];
    return $user;
}
Enter fullscreen mode Exit fullscreen mode

Store hashes in a column of at least 255 characters, because hash formats grow over time. Add rate limiting to the login endpoint (for example, a few attempts per minute per account and per IP address), show the same error for unknown accounts and wrong passwords, and offer multi-factor authentication for admin users at the very least.

Never roll your own crypto

Don't build password schemes from hash(), md5(), sha1() or custom salts, and don't encrypt passwords so they can be decrypted. password_hash() and password_verify() are the right tools. For encrypting other data, use the sodium extension (sodium_crypto_secretbox) rather than hand-made OpenSSL code.

Secure Sessions and Cookies

Stealing a session is often easier than cracking a password. Configure sessions so a cookie is hard to steal and useless if it is:

; php.ini session hardening
session.cookie_secure = 1        ; send the cookie over HTTPS only
session.cookie_httponly = 1      ; JavaScript cannot read it
session.cookie_samesite = "Lax"  ; "Strict" for admin areas
session.use_strict_mode = 1      ; reject session IDs PHP did not issue
session.use_only_cookies = 1
session.sid_length = 48
session.sid_bits_per_character = 6
session.gc_maxlifetime = 7200
Enter fullscreen mode Exit fullscreen mode

Call session\_regenerate\_id(true) after sign-in and after any change in privilege, as the login example above does. This prevents session fixation, where an attacker plants a known session ID before the victim logs in. On logout, clear the session array, delete the cookie and call session\_destroy().

Validate Input and Lock Down File Uploads

Escaping protects your output; validation protects your logic. Check type, length and format at the boundary with filter\_var() or a validation library, and reject unexpected input instead of trying to repair it.

<?php
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$age = filter_var($_POST['age'] ?? '', FILTER_VALIDATE_INT, ['options' => ['min_range' => 13, 'max_range' => 120]]);

if ($email === false || $age === false) {
    http_response_code(422);
    exit('Please check the highlighted fields.');
}
Enter fullscreen mode Exit fullscreen mode

File uploads deserve extra care. Detect the real MIME type with finfo rather than trusting the browser, generate your own file names, enforce size limits, and store uploads outside the web root (or in object storage) so an uploaded script can never be executed:

<?php
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'application/pdf' => 'pdf'];
$file = $_FILES['document'] ?? null;

if (!$file || $file['error'] !== UPLOAD_ERR_OK || $file['size'] > 5 * 1024 * 1024) {
    throw new RuntimeException('Upload failed or file too large.');
}

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($allowed[$mime])) {
    throw new RuntimeException('This file type is not allowed.');
}

$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($file['tmp_name'], '/var/www/app/storage/uploads/' . $name);
Enter fullscreen mode Exit fullscreen mode

Serve stored files through a controller that checks permissions and sends a Content-Disposition header, rather than linking to them directly.

Keep Dependencies and PHP Itself Patched

Most real-world breaches exploit known vulnerabilities in outdated code, not clever zero-days. Make checking automatic so it never depends on someone remembering:

# .github/workflows/security.yml
name: Security audit
on:
  push:
  schedule:
    - cron: '0 6 * * 1'   # every Monday, even when nobody pushes
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
      - run: composer install --no-interaction --no-progress
      - run: composer audit
Enter fullscreen mode Exit fullscreen mode

composer audit fails the build when an installed package has a published security advisory. Pair it with Dependabot or Renovate to open update pull requests, and stay on a PHP version that still receives security fixes. Check php.net/supported-versions for the dates.

Send Security Headers

HTTP response headers add a second line of defence that costs almost nothing. A Content-Security-Policy limits which scripts can run, so a missed escape is much harder to exploit. Set the headers once in Nginx (or in your framework's middleware) so every response carries them:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Enter fullscreen mode Exit fullscreen mode

Roll out a CSP in report-only mode first (Content-Security-Policy-Report-Only) so you can see what would break, then enforce it. Inline scripts need a nonce or hash once the policy is active.

How to Audit an Existing PHP Project in One Afternoon

Inherited a codebase? Work through these steps in order. Each one finds a different class of problem.

  1. Find string-built SQL: search for queries that include variables, such as grep -rnE "(query|exec)\\(.\*\\\\$" src/, and convert each one to a prepared statement.
  2. Find unescaped output: search templates for echo $, <?= $, {!! and |raw, and check every hit.
  3. Check forms: confirm every state-changing route verifies a CSRF token and that no GET route changes data.
  4. Check passwords: look for md5(, sha1( or crypt( near anything named password.
  5. Run the tools: composer audit, plus a static analyser such as PHPStan or Psalm at a strict level; Psalm's taint analysis can trace user input to dangerous sinks.
  6. Check the server: run php -i on production and compare it with the hardened settings above, then scan your headers with an online header checker.

Common PHP Security Mistakes

  • Escaping on input instead of output: data is stored “cleaned” for HTML and then used unsafely in SQL, JSON or email. Store raw data and escape at the moment of output.
  • Trusting client-side validation: JavaScript checks improve the experience but protect nothing; validate again on the server.
  • Leaking errors: display\_errors = On in production reveals paths, queries and sometimes credentials.
  • Secrets in the repository: keep credentials in environment variables or a secrets manager, and rotate any key that was ever committed.
  • Using rand() for tokens: use random\_bytes() or random\_int() for anything security-related.

A Quick PHP Security Checklist

  • Prepared statements for every database query
  • Context-aware output escaping
  • CSRF tokens on every state-changing request
  • Hardened php.ini and least-privilege file permissions
  • Argon2id or bcrypt password hashing with automatic rehashing
  • Secure, regenerated session cookies
  • Validated input and isolated uploads
  • composer audit in CI and a supported PHP version
  • Security headers on every response

Security is not a feature you add at the end. It is a set of defaults you build in from the first commit.


Originally published at phpscientist.com.

Top comments (0)