Article

PHP Security Modernization for Legacy Applications

Upgrade inherited PHP security practices: prepared statements, password hashing, sessions, CSRF, XSS escaping, uploads, secrets, and dependency audits—without a generic cyber encyclopedia.

Security modernization for legacy PHP is not a certification checklist pasted onto an old CMS. It is a sequence of concrete coding and configuration upgrades that remove the failure modes attackers still automate against PHP 5-era patterns: concatenated SQL, unsalted MD5 passwords, session cookies without flags, world-readable secrets, and debug output on production.

This flagship guide focuses on upgrading old application practices. For deeper single topics, continue to password hashing, sessions, database APIs, and Composer. Pair runtime upgrades with PHP 5 to modern PHP or PHP 7 to PHP 8 so removed extensions do not force insecure polyfills.

1. SQL: escaping myths → prepared statements

The removed mysql_* extension trained a generation to escape strings manually. Escaping is easy to skip on one branch; prepared statements with bound parameters make the data/command boundary explicit. When you modernize database access, treat prepared statements as the default—not as an optional cleanup after “getting it running.”

# LEGACY — concatenated SQL (also uses removed mysql_* APIs)
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = " . mysql_real_escape_string($id);
$result = mysql_query($sql);

# MODERN — PDO prepared statement + utf8mb4
$pdo = new PDO(
    'mysql:host=localhost;dbname=app;charset=utf8mb4',
    $user,
    $pass,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE id = ?');
$stmt->execute([(int) $_GET['id']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

Prioritize login, password reset, search, and any money-adjacent queries first. See mysql_connect removed if the app still fatals on boot under PHP 7+.

2. Password storage

Replace MD5/SHA-1 password columns using password_hash() / password_verify() and rehash on successful login when an outdated algorithm is detected. Do not invent homemade salts and stretch loops when the platform API already exists. Details and migration strategy: Password hashing modernization. Try parameters with the PHP Password Hash tool.

# LEGACY
$hash = md5($password);

# MODERN
$hash = password_hash($password, PASSWORD_DEFAULT);
if (password_verify($password, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        // store a fresh hash for this user
    }
}

3. Session cookies and fixation

Set session.cookie_httponly, session.cookie_secure (HTTPS), and session.cookie_samesite. Regenerate session IDs on privilege changes (login, role elevation). Avoid storing sensitive objects in sessions when a user id plus server-side lookup will do. Full guide: Session modernization.

# MODERN — cookie params before session_start()
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();
# after successful login:
session_regenerate_id(true);

4. CSRF on state-changing requests

Legacy forms that accept POST without a per-session token are vulnerable to cross-site request forgery. Add tokens to authenticating and mutating forms; reject mismatches server-side with timing-safe comparison. AJAX endpoints need the same check—obscure URLs are not protection. Prefer synchronizing tokens from the session rather than only checking Referer.

# MODERN — token check sketch
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
    http_response_code(403);
    exit('Invalid CSRF token');
}

5. XSS and output escaping

Escape at output time for HTML context with htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Do not “sanitize once on input” as your only control—data is reused in HTML, attributes, JS, and SQL differently. Prefer templating that auto-escapes when you can introduce it incrementally. Never drop raw database strings into admin HTML because “only staff see it.”

6. File uploads

  • Validate size and MIME/type with allowlists, not browser-provided types alone
  • Store uploads outside the executable document root when possible
  • Generate server-side filenames; never trust $_FILES['name'] as the storage path
  • Serve downloads through a script that sets Content-Type safely
  • Disable script execution in upload directories at the web server layer

7. TLS and cookies

Production should redirect HTTP to HTTPS, set Secure cookies, and avoid mixed content. Terminate TLS at the edge if you must, but ensure the app knows it is HTTPS (X-Forwarded-Proto handling only from trusted proxies). Session and “remember me” cookies without Secure on HTTPS sites undo other hardening.

8. Secret storage

# LEGACY — secrets in docroot
# public_html/config.php
$db_pass = 'hunter2';
$api_key = 'sk_live_example';

# MODERN — env outside webroot
$db_pass = getenv('APP_DB_PASSWORD') ?: '';
if ($db_pass === '') {
    throw new RuntimeException('Missing APP_DB_PASSWORD');
}

Keep .env files and credential includes outside the document root. Rotate anything that was ever committed to git history.

9. Debug exposure

Production must not display errors to browsers. Log instead. Misconfigured display_errors=On on a public host leaks paths and queries. Generate safe ini snippets with the Error Reporting Generator and read error handling modernization. Staging may show more detail behind VPN/IP allowlists—never copy that ini to the public pool.

10. Dependencies and cryptography

  • Run composer audit; replace abandoned packages (guide)
  • Use random_bytes() / random_int() instead of mt_rand() for security tokens
  • Do not invent encryption; use maintained libraries and modern algorithms
  • Retire mcrypt-era code paths during the PHP upgrade ladder (mcrypt_encrypt)
  • Avoid unserializing untrusted user input

11. Authorization is server-side

Hidden fields, disabled buttons, and “secret” admin URLs are not authorization. Every privileged action must re-check the authenticated user and role on the server. Legacy apps often check permissions in the menu renderer but not in the POST handler—fix both.

12. Headers and transport hygiene

As you touch front controllers, add conservative defaults appropriate to the app: avoid framing if you do not need it, set a baseline content type, and ensure cookies are not readable from JavaScript when unnecessary. Frameworks differ; the principle is to remove accidental openness rather than paste a giant header policy you do not understand.

Suggested remediation order

  1. Turn off public error display; confirm logs work
  2. Fix authentication/password storage and session cookies
  3. Replace injectable SQL on login and money paths first
  4. Add CSRF to admin and account forms
  5. Move secrets and tighten uploads
  6. Schedule dependency upgrades with tests
  7. Align PHP version upgrades so removed insecure extensions disappear for real

Workbench: Password Hash, Composer.json Validator, php.ini Generator, Config Diff, Environment Compare, Deprecated Checker. Official overview: PHP security manual. Process companion: inherited application playbook.

13. Input validation vs trusting client types

Legacy PHP often cast inputs casually ((int)$_GET['id']) in one place and concatenated strings in another. Standardize validation at the boundary: reject unexpected shapes early, then pass typed values into domain code. For emails, IDs, enums, and amounts, validation belongs on the server even if JavaScript already checked the form.

14. Logging without leaking secrets

As you increase error_reporting during modernization, scrub secrets from logs. Do not log raw passwords, full session IDs, payment payloads, or authorization headers. Prefer structured logs with request IDs. Connection failures should mention host/dbname—not passwords. Database guidance: database modernization.

15. Rate limiting and brute force basics

Login and password-reset endpoints in old apps are frequently unlimited. Add practical throttling (web server, WAF, or application counters) before advertising new public features on the same codebase. Pair with password_verify timing-safe checks and generic error messages that do not reveal whether a username exists, unless product requirements explicitly demand otherwise.

Security regression checks after each runtime bump

  • Confirm display_errors still off in production ini
  • Confirm session cookie flags survived php.ini template changes
  • Re-run composer audit
  • Hit CSRF-protected forms once on staging after session config edits
  • Verify upload directories still deny script execution

Compare ini and pool configs with Config Diff whenever PHP images change.

Related reading