PHP security is not a topic you bolt on at the end. It is four habits you build into ordinary code: never let user input become SQL, never let it become HTML, never store a password, and never assume a request came from your own page. This lesson shows each problem happening, then the one-line change that closes it. Everything here runs on your own machine against a demo database, so you can watch it break before you fix it.
SQL injection, in one query
The first PHP security hole nearly everyone writes is a lookup written the way it feels natural to write โ the username dropped straight into the string:
<?php
require "db.php";
// NEVER write a query like this.
$username = $_GET["username"] ?? "umair";
$sql = "SELECT username, email FROM members WHERE username = '$username'";
$rows = $pdo->query($sql)->fetchAll();
echo "Rows returned: " . count($rows) . "<br>";
foreach ($rows as $row) {
echo $row["username"] . " โ " . $row["email"] . "<br>";
}
?>
Ask for ?username=umair and it behaves. Ask for ?username=' OR '1'='1 and the string PHP builds is no longer a search for a name:
SELECT username, email FROM members WHERE username = '' OR '1'='1'
Output

Every row comes back, including the one that was never meant to leave the server. Nothing was hacked. MySQL did exactly what it was asked โ the problem is that the visitor got to help write the question.
And ' OR '1'='1 is the polite version. The same hole reads other tables, and with the right database permissions it writes to them.
The fix is a placeholder
The PHP security fix is one character. Prepared statements send the query and the values to MySQL separately. The query shape is fixed before your data is anywhere near it, so a value can never be read as instructions:
<?php
require "db.php";
$username = $_GET["username"] ?? "umair";
$stmt = $pdo->prepare("SELECT username, email FROM members WHERE username = ?");
$stmt->execute([$username]);
$rows = $stmt->fetchAll();
echo "Rows returned: " . count($rows) . "<br>";
?>
Output

Same URL, same input, zero rows โ because MySQL now genuinely looks for a member literally called ' OR '1'='1, and there isn't one. The injection became a search term, which is all it ever should have been.
This is not a trick, and it is not slower. It is simply the correct way to send a value to a database, and it is why every example in PHP MySQL uses prepare() and execute(). Escaping functions are the old advice; placeholders replace them entirely.
One thing placeholders cannot do is stand in for a table or column name. If you need a dynamic ORDER BY, match the input against a fixed allow-list of column names and use the matched value, never the raw input.
XSS: the danger is on the way out
The second PHP security problem is the mirror of the first. SQL injection is input reaching your database. Cross-site scripting is input reaching your page. Here is a comment box that prints what it is given:
<?php $comment = $_GET["comment"] ?? "Nice tutorial!"; ?>
<div style="border:1px solid #ccc; padding:10px;">
<?php echo $comment; ?>
</div>
Output

The tags in the input were not displayed โ they were applied. The browser had no way to tell your markup from the visitor's, because by the time it arrived there was no difference.
Bold text is harmless. A <script> tag is not: it runs with your site's privileges, in your visitor's browser, and can read their session cookie. That is the whole attack.
Escape on output, every time
One function fixes it, and it is the PHP security habit you will use most often. htmlspecialchars() converts the characters that mean something in HTML into their harmless text equivalents:
<div style="border:1px solid #ccc; padding:10px;">
<?php echo htmlspecialchars($comment, ENT_QUOTES, "UTF-8"); ?>
</div>
Output

Same input, and now the browser shows the characters instead of obeying them. < became <, which renders as a less-than sign and nothing more.
Two arguments are worth passing deliberately. ENT_QUOTES escapes single quotes as well as double, which matters when you echo into an attribute. "UTF-8" states the encoding rather than relying on a default.
The important part is where this goes. Escape when you print, not when you store. Data that was cleaned on the way in still has to be printed somewhere, and the same value might later go into a page, an email and a JSON response โ each of which needs different treatment. Store it raw, escape at the moment of output, and you only have to be right once per echo.
Never store a password
Password storage is the PHP security decision with the worst consequences when it goes wrong. You do not need to know anyone's password. You only need to recognise it later, and PHP has two functions for exactly that:
<?php
$plain = "correct-horse-battery-staple";
$hash = password_hash($plain, PASSWORD_DEFAULT);
echo "Stored hash: " . $hash . "<br>";
echo "Second hash: " . password_hash($plain, PASSWORD_DEFAULT) . "<br>";
var_dump(password_verify("correct-horse-battery-staple", $hash));
var_dump(password_verify("wrong-password", $hash));
?>
Output

Look at the two hashes: same password, completely different strings. password_hash() generates a random salt each time and stores it inside the result, so two people with the same password get different rows and a stolen database cannot be attacked wholesale.
Which is also why you cannot compare hashes with ===. password_verify() pulls the salt out of the stored hash, re-hashes the attempt with it, and compares the results in constant time.
Use PASSWORD_DEFAULT rather than naming an algorithm. It tracks whatever PHP currently considers best, which is the point. And make the column VARCHAR(255) โ today's hash is 60 characters, but the constant exists precisely because that will change.
md5() and sha1() are not password functions. They are fast, which is a virtue for checksums and a fatal flaw for passwords.
CSRF: the request that was not yours
The subtlest PHP security problem of the four. A visitor logs into your site, then opens another tab with a page that quietly submits a form to your /delete-account URL. The browser attaches your visitor's cookies, because that is what browsers do. Your server sees a perfectly valid, fully authenticated request.
The defence is to prove the form came from a page you served. Put an unguessable token in the session and in the form, and compare them:
<?php
session_start();
if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32));
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$sent = $_POST["csrf_token"] ?? "";
if (!hash_equals($_SESSION["csrf_token"], $sent)) {
$message = "Rejected: the token did not match.";
} else {
$message = "Accepted: this form really came from our own page.";
}
}
?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION["csrf_token"]; ?>">
<button type="submit">Delete</button>
</form>
Output

The other site can make the browser send a request. It cannot read your session to learn the token, so its request arrives without one and gets rejected.
Two details matter. random_bytes() is cryptographically random โ rand() and uniqid() are not, and a guessable token is no token. And hash_equals() compares in constant time, so an attacker cannot learn the token one character at a time by measuring how long the comparison takes.
Only state-changing requests need this. A search form does not.
Three mistakes with PHP security
Cleaning input on the way in and calling it done. Stripping tags at the point of storage feels thorough and solves the wrong problem. The same value still has to be escaped for whichever context it lands in later, and you have permanently damaged the data. Validate on input, escape on output.
Trusting anything the browser sends. Hidden fields, cookies, the Referer header, a disabled input, JavaScript validation โ all of it is under the visitor's control. Client-side checks are a courtesy to honest users. The server has to check again.
Leaking information in error messages. "No such user" and "Wrong password" together tell an attacker which usernames exist. A raw database error tells them your table names. One vague message for the visitor, the detail in your log.
Common questions about PHP security
The questions that come up as soon as a site has real users on it.
Does using PDO automatically protect me from SQL injection?
No. PDO with prepare() and placeholders protects you. PDO with the value concatenated into query() is exactly as vulnerable as the old functions were. The protection comes from the placeholder, not the library.
Is mysqli or PDO more secure?
Neither. Both support prepared statements and both are safe when you use them. PDO is usually preferred for its cleaner API and support for other databases, which is why the series leans on it โ but a prepared statement in mysqli is just as safe.
Should I still use mysql_real_escape_string?
No. It was removed from PHP years ago, and its mysqli equivalent exists for legacy code rather than new code. Escaping puts the burden on you to never miss a spot; placeholders remove the possibility.
How do I safely display HTML that a user submitted?
If you genuinely need to allow some tags โ a comment box with bold and links โ do not write the filter yourself. Use a maintained sanitiser library with a strict allow-list of tags and attributes. Writing your own means keeping up with every browser parsing quirk, which is a losing race.
Is HTTPS enough to make my site secure?
No. HTTPS protects data while it travels. It does nothing about SQL injection, XSS, weak password storage or CSRF, because those all happen at the two ends. You need it, and you need everything on this page as well.
What is next
These four PHP security habits are the foundation, and the natural place to put them all together is the feature every site eventually needs: accounts.
The next lesson builds a working PHP login system โ registration, hashed passwords, a session that survives page loads, and a page that turns strangers away. It uses PHP sessions and PHP MySQL together, so a quick look back at either will not hurt.
Loading comments...