Most PHP vulnerabilities come from the same handful of mistakes, and each has a well-established fix that takes one line. This guide covers the seven that account for nearly all of them: SQL injection, cross-site scripting, cross-site request forgery, weak password storage, unsafe file handling, leaky sessions and error messages that tell attackers too much.
The single idea underneath everything#
Nearly every vulnerability on this page is the same mistake wearing different clothes: data supplied by a user ends up being treated as instructions. A name becomes part of an SQL query. A comment becomes part of your HTML. A filename becomes part of a path. The fix is always to keep the two apart, and every technique below is a way of doing that in one particular place.
1. SQL injection: use prepared statements#
Here is the vulnerable pattern:
<?php
// DANGEROUS
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $pdo->query($sql);
An input of ' OR '1'='1 turns that into a query matching every row. Worse inputs can drop tables or read other ones.
Prepared statements send the query and the data to the database separately, so user input can never change the query’s structure:
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch();
Named placeholders read better when there are several:
<?php
$stmt = $pdo->prepare(
'INSERT INTO posts (title, body, author_id) VALUES (:title, :body, :author)'
);
$stmt->execute([
':title' => $_POST['title'],
':body' => $_POST['body'],
':author' => $userId,
]);
Set up PDO so failures are loud and emulation is off:
<?php
$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,
]);
2. Cross-site scripting: escape on output#
<?php
// DANGEROUS - a comment containing a script tag now runs
echo '<p>' . $comment . '</p>';
<?php
function e(?string $value): string {
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo '<p>' . e($comment) . '</p>';
echo '<a href="' . e($url) . '">' . e($label) . '</a>';
Escape when you print, not when you save. Storing escaped text means you cannot use it anywhere else — not in an email, not in a JSON response, not in a PDF — without unpicking it first, and each context needs different escaping anyway.
Different places need different treatment. Inside a script tag, encode as JSON:
<script>
const user = <?= json_encode($name, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
</script>
3. CSRF: prove the request came from your form#
Without a token, any other website can make a visitor’s browser submit your form while logged in.
<?php
session_start();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post" action="/update">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<!-- the rest of the form -->
</form>
<?php
// On the receiving page
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request.');
}
Use hash_equals() rather than ===. It compares in constant time, which removes a subtle timing attack.
4. Passwords: hash, never encrypt#
<?php
// Storing
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);
// Checking
if (password_verify($_POST['password'], $user['password_hash'])) {
// correct
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$new = password_hash($_POST['password'], PASSWORD_DEFAULT);
// save $new
}
}
password_hash() generates its own salt and stores it inside the resulting string, so you do not manage salts yourself. Never use md5() or sha1() for passwords — they are designed to be fast, which is exactly the wrong property here.
5. File uploads and paths#
<?php
if ($_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
exit('Upload failed.');
}
$tmp = $_FILES['photo']['tmp_name'];
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$mime = mime_content_type($tmp);
if (!isset($allowed[$mime])) {
exit('Only JPEG and PNG images are accepted.');
}
$name = bin2hex(random_bytes(8)) . '.' . $allowed[$mime];
move_uploaded_file($tmp, __DIR__ . '/uploads/' . $name);
Three defences in a few lines: check the real content type rather than the name the browser sent, generate your own filename, and store uploads outside the web root or in a folder configured not to execute PHP.
For reading files by name, resolve and confirm the path stays inside the intended folder:
<?php
$base = realpath(__DIR__ . '/documents');
$target = realpath($base . '/' . basename($_GET['file'] ?? ''));
if ($target === false || !str_starts_with($target, $base . DIRECTORY_SEPARATOR)) {
http_response_code(404);
exit;
}
6. Sessions#
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // not readable from JavaScript
'samesite' => 'Lax', // blocks most cross-site sending
]);
session_start();
// After a successful login, always:
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
Regenerating the ID at login prevents session fixation, where an attacker sets a known session ID before the victim logs in and then reuses it.
7. Errors and configuration on a live server#
display_errors = Off
log_errors = On
error_log = /var/log/php/error.log
expose_php = Off
A stack trace shown to a visitor gives away file paths, framework versions and sometimes credentials. Log everything, display nothing.
A few headers cost nothing and close off common attacks:
<?php
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
header('Content-Security-Policy: default-src \'self\'');
A checklist before launch#
- Every query uses prepared statements, including the ones in admin pages
- Every echoed variable goes through your escaping helper
- Every state-changing form has a CSRF token that is checked
- Passwords use
password_hashand the column is 255 characters - Uploads get generated names and are stored where PHP will not execute them
- Session cookies are secure, HTTP-only and SameSite
display_errorsis off, logging is on- The site is served over HTTPS and PHP is a currently supported version
Questions people ask#
Is mysqli_real_escape_string enough?
No. It can protect a correctly quoted value, but it depends on the connection character set being right and it does nothing for values you forgot to quote. Prepared statements remove the whole class of mistake instead of mitigating it.
Should I use a framework?
If you are building anything with accounts or payments, yes. Laravel and Symfony give you CSRF protection, escaping in templates and prepared statements by default. You can write secure plain PHP, but you have to remember every rule on this page every time.
Do I need to sanitise input as well as escape output?
Validate input for shape — is this really an email address, is this number in range — and reject what fails. Do not try to “clean” dangerous characters out of it; that is fragile. Escaping belongs at output, where you know the context.
How often should I update PHP?
Stay on a version that still receives security fixes, and apply patch releases as they come. Running an end-of-life PHP version means known vulnerabilities with published exploits and no fix coming.
Where to go next#
- Handling errors and exceptions in PHP — failing safely rather than loudly.
- Working with forms in PHP — where most untrusted input arrives.
- Working with files and directories in PHP — the path traversal section in more depth.