Web Security: XSS, CSRF & Passwords
The security baseline every employer expects — escaping output to stop XSS, CSRF tokens to stop forged requests, and hashing passwords the right way.
What you will learn
- Prevent cross-site scripting (XSS) by escaping output
- Protect forms with CSRF tokens
- Hash and verify passwords with passwordhash / passwordverify
Security is not optional
You already know about SQL injection (use prepared statements). But employers expect three more defences as a baseline, and missing any one of them can sink a real site. This lesson covers the big three: XSS, CSRF and password hashing. None are hard — they are just habits you apply every time.
XSS — escape everything you output
XSS (cross-site scripting) is when an attacker sneaks <script> into your page — say, by typing it into a comment box — and it then runs in other visitors’ browsers, stealing their data. The defence is simple and absolute: escape any user-supplied text before printing it, so tags become harmless characters. htmlspecialchars() does exactly this.
<?php
// imagine a comment a user typed:
$comment = '<script>alert("hacked")</script>';
echo "Unsafe: " . $comment; // DANGER
echo "<br>Safe: " . htmlspecialchars($comment); // neutralised
?>The first echo prints the comment as-is — the browser would treat <script> as real code and run it. The second wraps it in htmlspecialchars(), which converts < into <, > into > and so on, so the browser shows the text literally instead of executing it. The script becomes visible, harmless text.
Note: Output (in the browser):
Unsafe: (an alert box pops up — the attack ran!)
Safe: <script>alert("hacked")</script>
The safe line displays the tags as plain text; nothing runs. The rule is mechanical: every piece of user data you echo into a page gets wrapped in htmlspecialchars(). No exceptions.
CSRF — prove the request came from your form
CSRF (cross-site request forgery) is when another site tricks a logged-in user’s browser into silently submitting a form to *your* site — like a hidden "delete my account" request. The defence is a CSRF token: a secret random value your form includes and your server checks. A forged request from another site cannot know the token, so it is rejected.
It works in three steps:
- When you show the form, generate a random token, store it in the session, and put it in a hidden field.
- The browser sends the token back with the form.
- On submit, compare the token from the form with the one in the session — if they do not match, reject the request.
<?php
session_start();
// when SHOWING the form: make and remember a token
$_SESSION["csrf"] = bin2hex(random_bytes(32));
?>
<form action="delete.php" method="post">
<input type="hidden" name="csrf" value="<?= $_SESSION["csrf"] ?>">
<button>Delete account</button>
</form>bin2hex(random_bytes(32)) creates a long, unguessable random token; we save it in $_SESSION["csrf"] and also drop it into a hidden form field. The user never sees it, but it travels back when they submit — proving the request really came from a page *you* served.
<?php
// delete.php — when RECEIVING the form
session_start();
if (($_POST["csrf"] ?? "") !== ($_SESSION["csrf"] ?? "_")) {
http_response_code(403);
exit("Invalid request.");
}
// token matched — safe to proceed
echo "Account deleted.";
?>On submit, we compare the token the form sent ($_POST["csrf"]) with the one we stored in the session. If they differ, the request did not come from our form — we send a 403 "forbidden" and stop. Only a matching token lets the dangerous action proceed.
Note: Output (a normal user clicking the button):
Account deleted.
A forged request from a malicious site would carry no valid token, so the comparison fails and it sees Invalid request. instead. This is exactly what Laravel’s automatic @csrf does for you behind the scenes.
Passwords — hash, never store plain
You must never store a password as plain text — if your database leaks, every account is exposed. Instead you store a hash: a scrambled, one-way version that cannot be reversed back to the password. PHP makes this safe and easy with password_hash() to scramble and password_verify() to check a login.
<?php
// when the user SIGNS UP: store the hash, not the password
$password = "secret123";
$hash = password_hash($password, PASSWORD_DEFAULT);
// save $hash in the database
// when the user LOGS IN: verify the typed password against the hash
$typed = "secret123";
if (password_verify($typed, $hash)) {
echo "Login successful";
} else {
echo "Wrong password";
}
?>On signup, password_hash($password, PASSWORD_DEFAULT) turns the password into a long hash (it also adds random "salt" automatically, so identical passwords hash differently) — and that is what you store, never the password itself. On login, password_verify($typed, $hash) checks whether the typed password matches the stored hash, returning true or false. You never decrypt anything, because a hash cannot be undone.
Note: Output (in the browser):
Login successful
The stored hash looks like $2y$10$N9qo8uLOickgx2ZMRZoMy... — unreadable and irreversible. Even if attackers steal your database, they cannot recover the original passwords. This is the only acceptable way to handle passwords.
Watch out: Never invent your own password scrambling (md5, sha1, "reverse the letters") — they are all broken. Always use password_hash() / password_verify(), which use strong, modern algorithms and are updated by PHP over time.
Q. How should you store user passwords?
✍️ Practice
- Echo a user comment safely with
htmlspecialcharsand confirm a<script>tag does not run. - Hash a password with
password_hashand verify it withpassword_verify.
🏠 Homework
- Add a CSRF token to a form (generate, hide, verify on submit) and reject any submission with a missing or wrong token.