PHP Security: Preventing SQL Injection, XSS and CSRF
What does PHP security involve?
PHP security is the practice of writing and running PHP applications so attackers cannot read, change or destroy data they should not reach. In practice it means using prepared statements against SQL injection, escaping output against cross-site scripting, checking CSRF tokens on forms, hashing passwords properly, validating uploads and configuring the server so errors and secrets are never exposed.
Key threats and the terms behind them
Most attacks on PHP sites exploit a small number of well-known weaknesses. The OWASP Top 10, a widely used awareness list of web application security risks, is a good reference for the wider picture. These are the ones every PHP developer must handle:
- SQL injection: user input changes the meaning of a database query.
- Cross-site scripting (XSS): user input is output as HTML or JavaScript and runs in other visitors' browsers.
- Cross-site request forgery (CSRF): another site makes a logged-in user's browser submit a form to your site.
- Weak password storage: passwords saved in plain text or with fast hashes such as MD5 can be recovered after a leak.
- Unsafe uploads: an uploaded file turns out to be a script the server will execute.
The examples here are defensive. Test them only on applications you own or are authorised to test. For the network side of defence, see our cybersecurity basics guide.
How prepared statements stop SQL injection
Building SQL by joining strings lets input such as ' OR '1'='1 rewrite your query. A prepared statement sends the query structure and the data separately, so input is always treated as a value, never as SQL.
<?php
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
'shop_app',
getenv('DB_PASSWORD'),
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// Unsafe: "SELECT * FROM users WHERE email = '$email'"
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email'] ?? '']);
$user = $stmt->fetch(PDO::FETCH_ASSOC);Placeholders work for values only. Table names, column names and sort directions cannot be bound, so check them against an allow-list such as in_array($sort, ['name', 'price'], true). The MySQL fundamentals guide explains the queries themselves.
Escaping output against XSS and checking CSRF tokens
Escape every value at the moment you print it into HTML. Pass the flags and encoding explicitly so behaviour does not depend on configuration.
<?php
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// Create one token per session
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<p>Hello, <?= e($user['name']) ?></p>
<form method="post" action="/profile">
<input type="hidden" name="csrf_token" value="<?= e($_SESSION['csrf_token']) ?>">
<button>Save</button>
</form>
<?php
// When the form is submitted
$sent = (string) ($_POST['csrf_token'] ?? '');
if (!hash_equals($_SESSION['csrf_token'], $sent)) {
http_response_code(403);
exit('Invalid request');
}ENT_QUOTES escapes both single and double quotes, which protects attribute values. htmlspecialchars() is for HTML only: use rawurlencode() inside URLs and json_encode() with JSON_HEX_TAG when passing data into JavaScript. hash_equals() compares in constant time, so timing differences do not leak the token. Pair tokens with SameSite cookies as described in the sessions and cookies guide.
Passwords, validation and file uploads
Store passwords with password_hash($password, PASSWORD_DEFAULT) in a VARCHAR(255) column and check them with password_verify(). The function creates a random salt and a slow hash for you. Call password_needs_rehash() after a successful login so old hashes upgrade when PHP changes its default.
Validation and escaping are different jobs. Validate input when it arrives: is this a valid email, a whole number in range, one of the allowed options? Escape output when it leaves, according to where it goes. Validation does not make data safe to print, and escaping does not make data correct.
<?php
$qty = filter_var($_POST['qty'] ?? '', FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 10]]);
if ($qty === false) { /* show an error */ }
$file = $_FILES['photo'] ?? null;
if ($file && $file['error'] === UPLOAD_ERR_OK && $file['size'] <= 2_000_000) {
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (isset($allowed[$mime])) {
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($file['tmp_name'], '/var/app/uploads/' . $name);
}
}Never trust the browser-supplied file name or type. Detect the type from the file contents, generate a new name, and store uploads outside the public web root or in a folder where the server will not execute scripts.
Securing a PHP application step by step
- Replace every query built with string joining by a PDO prepared statement.
- Route all output through an escaping helper or a template engine that escapes by default.
- Add a CSRF token to every form that changes data and reject requests without a valid one.
- Move passwords to
password_hash()and rehash old ones on next login. - In production
php.ini, setdisplay_errors = Off,log_errors = Onand keeperror_reporting = E_ALL, so errors are logged but never shown. - Send security headers such as
Content-Security-Policy,X-Content-Type-Options: nosniff,Referrer-Policyand, on HTTPS sites,Strict-Transport-Security. - Run
composer auditto check dependencies for known vulnerabilities, update regularly, and stay on a PHP version that still receives security fixes. - Connect with a database user that has only the rights the application needs, never the root account.
Security headers can be sent from PHP before any output, or set once in the web server configuration. A starting set looks like this; tighten the Content-Security-Policy to match the scripts and styles your site actually loads.
<?php
header("Content-Security-Policy: default-src 'self'; frame-ancestors 'none'");
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');For the database account, create a dedicated user and grant only the statements the application runs:
CREATE USER 'shop_app'@'localhost' IDENTIFIED BY 'a-long-random-secret';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost'; Recommended practices at a glance
| Risk | Defence in PHP |
|---|---|
| SQL injection | PDO prepared statements, allow-lists for identifiers |
| XSS | htmlspecialchars with ENT_QUOTES and UTF-8, Content-Security-Policy |
| CSRF | random_bytes token, hash_equals check, SameSite cookies |
| Password leaks | password_hash and password_verify |
| Malicious uploads | finfo type check, random names, storage outside web root |
| Information leaks | display_errors off, logs kept private |
| Vulnerable libraries | composer audit and regular updates |
Access control deserves the same care. Every request that reads or changes a record must check that the logged-in user is allowed to touch that specific record, not just that someone is logged in. Changing /orders/101 to /orders/102 in the address bar should never show another customer's order.
Practical exercise: audit and fix a guestbook
On your own machine, write a deliberately weak guestbook: a form posting a name and message, saved with a string-joined SQL query and printed without escaping. Then fix it.
- Show that a message containing
<b>tags renders as bold, proving output is not escaped. - Convert the insert and select queries to prepared statements.
- Escape every printed value with your
e()helper. - Add a CSRF token and confirm a form submitted without it receives a 403 response.
- Create a limited database user and switch the app to it.
You are done when HTML in messages appears as plain text, a quote mark in a name no longer breaks the query, and forged form posts are rejected. These habits are practised throughout the advanced PHP programme.
Common mistakes
- Escaping input with
addslashes()orhtmlspecialchars()before saving it, instead of using prepared statements and escaping on output. - Relying on JavaScript or HTML form validation, which any visitor can bypass.
- Comparing tokens with
==instead ofhash_equals(). - Leaving
display_errorson in production, which reveals file paths and query details. - Connecting to the database as root, so a single injection bug exposes every database on the server.
- Hashing passwords with
md5()orsha1(), which are too fast to resist guessing.
Frequently asked questions
Do frameworks make PHP secure automatically?
Frameworks such as Laravel and CodeIgniter provide query builders, escaping helpers and CSRF protection, but you can still bypass them with raw queries or unescaped output. You need to understand the threats to use the tools correctly.
Is mysqli safe, or must I use PDO?
Both support prepared statements and both are safe when used correctly. PDO works with many databases through one interface and supports named placeholders, which is why many projects prefer it.
Should I sanitise all input on arrival?
Validate it on arrival and reject what is not acceptable, but do not try to clean it for every possible output. Store the original value and escape it for each context, such as HTML, URL or SQL, at the point of use.
Does WordPress follow the same rules?
Yes. WordPress offers its own functions, such as esc_html() and $wpdb->prepare(), built on the same ideas. The WordPress guide explains how themes and plugins are structured.