Security Essentials: XSS, SQL Injection, and CSRF
A house has a lock on the front door, a latch on the windows, and a peephole. No single one keeps everyone out, but together they make break-ins hard. Website security works the same way: several simple habits, used every time.
A website is open to the whole world, and some visitors aren't friendly. The good news: most attacks on PHP sites exploit a handful of well-known mistakes, and each has a well-known fix. You've met two already. This lesson gathers them into one checklist, and adds two more.
1. Escape output: stopping XSS
The attack: someone submits text containing HTML or JavaScript, like <script>...</script>, and your page shows it to other visitors, whose browsers run it. That's cross-site scripting (XSS), from Validating and Escaping User Input.
The fix: escape every outside value with htmlspecialchars at the moment you put it into HTML.
<?php
function e(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
$comment = '<img src=x onerror="alert(1)">Nice post!';
echo "<p>" . e($comment) . "</p>\n";<p><img src=x onerror="alert(1)">Nice post!</p>"Outside values" means form data, but also anything from the database, files, cookies, or the address bar, since visitors may have put it there earlier.
2. Use placeholders: stopping SQL injection
The attack: someone types SQL into a form, and your code pastes it into a query, changing what the query does. That's SQL injection, from Databases with PDO.
The fix: every outside value goes through a placeholder in a prepared statement. Never build SQL by joining strings with user input.
<?php
$db = new PDO("sqlite::memory:");
$db->exec("CREATE TABLE users (name TEXT)");
$db->exec("INSERT INTO users VALUES ('Maria'), ('Ben')");
$typed = "x' OR '1'='1";
$stmt = $db->prepare("SELECT COUNT(*) FROM users WHERE name = ?");
$stmt->execute([$typed]);
echo "Users found: " . $stmt->fetchColumn() . "\n";Users found: 03. Hash passwords: never store them
The risk: if your database is ever stolen, and passwords are stored as plain text, every account is exposed, and since people reuse passwords, their email and bank accounts too.
The fix: never store the password itself. Store a hash: a scrambled fingerprint that can check a password, but can't be turned back into it. PHP makes this easy with two functions:
<?php
$hash = password_hash("sunflower42", PASSWORD_DEFAULT);
echo "Stored: " . substr($hash, 0, 7) . "... (" . strlen($hash) . " characters)\n";
var_dump(password_verify("sunflower42", $hash));
var_dump(password_verify("Sunflower42", $hash));Stored: $2y$12$... (60 characters)
bool(true)
bool(false)password_hashmakes the hash. At sign-up, save this in the database.password_verifychecks a typed password against a stored hash. At login, use this.PASSWORD_DEFAULTuses PHP's current recommended method (bcrypt, which is what the$2y$at the start means). Its hashes can be up to 255 characters in the future, so give the database column room.- Each hash includes a random salt, so hashing the same password twice gives two different hashes. That's why you can't check a password by hashing it again and comparing with
===; always usepassword_verify.
Never write your own password scrambling, and never use fast hash functions like md5 or sha1 for passwords. They're far too quick to guess.
A login check then looks like this:
<?php
function login(PDO $db, string $email, string $password): bool
{
$stmt = $db->prepare("SELECT id, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user === false || !password_verify($password, $user["password_hash"])) {
return false;
}
session_regenerate_id(true);
$_SESSION["user_id"] = $user["id"];
return true;
}When the login fails, tell the visitor "Wrong email or password," not which one was wrong. Otherwise, attackers can use your login page to find out which emails have accounts.
4. CSRF tokens: stopping forged forms
The attack: Maria is logged in to your shop. She visits another, malicious site, which contains a hidden form that submits to your site, say, "change email to attacker@example.com", and sends it automatically. Her browser attaches her session cookie, so your site thinks Maria sent it. That's cross-site request forgery, or CSRF.
The fix: every form that changes something includes a secret, random token that only your site knows, stored in the session. The other site can't read it, so its forged form can't include it.
<?php
session_start();
// Once per session: make a random token.
if (!isset($_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)) {
http_response_code(403);
exit("This form has expired. Please go back and try again.");
}
// ... safe to handle the form ...
}
?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
<label for="email">New email</label>
<input type="email" id="email" name="email">
<button>Save</button>
</form>random_bytes(32)makes 32 truly random bytes, andbin2hexturns them into 64 letters and digits.- The token goes in a hidden field, so it's sent with the form.
hash_equalscompares the two strings safely. (A plain===can, in theory, leak how many characters matched, through tiny timing differences.)http_response_code(403)sends the "Forbidden" status, andexitstops the script with a message.
The samesite cookie setting from Sessions and Cookies blocks many CSRF attacks too, but tokens are the dependable fix. Frameworks like Laravel add them to every form automatically.
The rest of the checklist
- Validate all input on the server, as in Validating and Escaping User Input.
- Use HTTPS on your live site, so passwords and session cookies are encrypted in transit, and set cookies to
secure. - Hide error details from visitors on a live site, and log them instead, as in Errors and Exceptions.
- Keep secrets out of your code: database passwords go in a settings file outside the public folder and outside Git.
- Keep PHP and your Composer packages up to date. Security fixes arrive in new versions. Composer can check your packages:
composer audit. - Be careful with uploads: never trust a file's name or type, and never save uploads where the web server would run them as PHP.
Try it
A sign-up and login flow with the essentials in place. Predict every line.
Running PHP here
Running PHP right in the browser is coming to Syntaxia soon. For now, you can read and edit the code here. If you have PHP installed on your computer (see Setting Up), save it as index.php and run it with php index.php.
Check your prediction
Welcome, <b>Joy</b>!
Wrong email or password.
Wrong email or password.
bool(false)
bool(true)The first login matches the hash. The second has a capital M, and passwords are case-sensitive. The injection attempt goes through a placeholder, so it's just an email address nobody has. The name is escaped on output, so the <b> tags show as text. And the stored value is a hash: the password itself appears nowhere in the database.
Try it yourself
- Add a second user, and check that each can only log in with their own password.
- Try signing up
joy@example.coma second time. What happens, and how would you show a friendly message instead? - Print
password_hash("test", PASSWORD_DEFAULT)twice. Are the two hashes the same? Doespassword_verifyaccept both?
Check your understanding
Up next
Your code can be secure and still hard to work with. The last lesson before the final project collects the habits that make PHP code clean, readable, and easy to change: Best Practices and Common Mistakes.
