CCelestiallines.com
← Back to blog

PHP Security Best Practices: Preventing SQL Injection, XSS, and CSRF

By Pawan Singh · Jun 2026

Most PHP security incidents trace back to a small, well-known set of mistakes, not exotic zero-days. Getting these fundamentals right closes the overwhelming majority of real-world attack surface.

SQL Injection: Always Use Prepared Statements

Never concatenate user input into a query string — ever, including for "just an admin tool" or "internal only" code, since both of those assumptions tend to stop being true over time:

// Vulnerable: user input becomes part of the SQL itself
$result = $pdo->query("SELECT * FROM users WHERE email = '$email'");

// Safe: the driver handles escaping, input is never treated as SQL
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

Prepared statements aren't just "escaping" — the query structure and the data are sent to the database separately, so injected SQL syntax in the data has no way to alter the query at all, regardless of what characters it contains. Every mainstream PHP framework (Laravel's Eloquent/query builder, Doctrine, Symfony) uses prepared statements under the hood by default; the risk shows up almost exclusively in hand-written raw SQL.

XSS: Escape Output, Not Just Input

Cross-site scripting happens when unescaped user data is rendered as HTML, letting an attacker inject a <script> tag or event handler that runs in another user's browser:

// Vulnerable: a username like <script>stealCookies()</script> executes
echo "Welcome, $username";

// Safe
echo "Welcome, " . htmlspecialchars($username, ENT_QUOTES, 'UTF-8');

The common misconception is that sanitizing input at the point of collection is enough — it isn't, because the same data might later be output into an HTML attribute, a <script> block, or a URL, each with different escaping rules. Escape at the point of output, in the context that output is used, every time. Template engines (Blade, Twig) auto-escape by default — know exactly which raw-output syntax in your template engine bypasses that (Blade's {!! !!}, Twig's |raw) and audit every use of it, since each one is a deliberate opt-out of your main XSS defense.

CSRF: Verify the Request Actually Came From Your Site

Cross-Site Request Forgery tricks a logged-in user's browser into submitting a request to your site from a different, attacker-controlled page — the browser happily attaches the victim's real session cookie, since cookies aren't scoped to "which page initiated this request."

The fix is a CSRF token: a random value embedded in your form, tied to the user's session, verified on submit:

<input type="hidden" name="_token" value="<?= $csrfToken ?>">

An attacker's page has no way to know or forge that token, so their forged submission fails validation even with a valid session cookie riding along. Laravel, Symfony, and most modern frameworks generate and verify this automatically for every form — the actual risk in framework-based apps is usually a developer explicitly disabling CSRF protection for an endpoint (often "just for now" while debugging) and forgetting to re-enable it.

Password Storage

Never store passwords in plain text or with a fast general-purpose hash like MD5/SHA-1 — both are designed to be fast, which is exactly the wrong property for password hashing, since it makes brute-forcing cheap. Use PHP's built-in, purpose-built function instead:

$hash = password_hash($password, PASSWORD_DEFAULT);
// later, verifying login:
if (password_verify($submittedPassword, $hash)) { /* ... */ }

PASSWORD_DEFAULT automatically uses the strongest algorithm PHP currently recommends (bcrypt today, subject to change in future PHP versions without you needing to update your code) and handles salting internally — there's no reason to hand-roll this.

File Uploads

Never trust a file's claimed MIME type or its extension — both are trivially spoofable by whoever uploads the file. Validate the actual file content server-side (e.g. checking real image dimensions/magic bytes for an "image" upload), store uploads outside the public web root (or serve them through a controller rather than directly), and never execute anything from an upload directory — a misconfigured server that executes PHP files dropped into an "images" folder is a classic path to full remote code execution.

Keep Dependencies Updated

A meaningful share of real-world PHP breaches exploit a known, already-patched vulnerability in an outdated dependency, not a bug in your own code. Run composer audit periodically (it checks installed packages against a known-vulnerability database) and actually act on what it reports, rather than letting composer.lock drift for years.

A Minimal Checklist

  • All database queries use prepared statements, no exceptions for "trusted" input.
  • All dynamic output is escaped for the context it's rendered in.
  • CSRF tokens are on by default; any disabled endpoint is a deliberate, reviewed decision.
  • Passwords go through password_hash()/password_verify(), never a raw hash function.
  • Uploaded files are validated by content, stored outside the web root, and never executable.
  • composer audit runs regularly, ideally as part of CI.

Worried about the security posture of an existing PHP application? Get in touch — I work as a freelance PHP developer.

Comments