A website on the public internet is visited not only by real users but also by automated tools probing for weaknesses, all day, every day. Most successful attacks on PHP sites don't use clever new tricks. They use a handful of well-known mistakes: printing user text without escaping it, building SQL from strings, trusting that a form was submitted from your own site, or leaving error messages visible.
Each of those mistakes has a simple, standard fix. This lesson collects them in one place, explains why each attack works, and shows the code that stops it.
You'll defend against cross-site scripting (XSS), SQL injection and cross-site request forgery (CSRF), harden sessions, handle file uploads safely, and configure PHP so errors and secrets don't leak to visitors.
Cross-site scripting (XSS)
XSS happens when text from a user is printed into a page as HTML. If someone saves a comment containing <script>, every visitor who views the comment runs that script, which can steal their session or change the page.
The fix is to escape every value when you print it:
echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');This turns < into <, " into " and so on, so the browser shows the text instead of running it. Escape at the moment of output, every time, including values from your own database. Template engines such as Twig and Laravel's Blade ({{ $name }}) escape automatically.
SQL injection
SQL injection happens when user input is joined into an SQL string and changes the query's meaning. An email typed as ' OR '1'='1 can turn a login check into "find any user".
The fix is prepared statements with placeholders, as covered in the PDO lesson and the SQL security lesson:
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);The database receives the SQL and the values separately, so input is always treated as data. Also connect with a database user that only has the permissions the site needs.
Cross-site request forgery (CSRF)
CSRF tricks a logged-in user's browser into submitting a form to your site from another site. The browser sends your session cookie along, so without protection your site thinks the user meant it.
The fix is a CSRF token: a random value stored in the session and included as a hidden field in every form that changes data. When the form is submitted, compare the two with hash_equals(). An attacker's site can't read the token, so its forged form fails. Setting the session cookie's SameSite attribute to Lax blocks most cross-site form posts too, and the two together give strong protection.
Sessions, uploads and errors
- Session cookies: set
HttpOnly(JavaScript can't read it),Secure(HTTPS only) andSameSite=Lax, and callsession_regenerate_id(true)when a user logs in. - Uploads: check the size, check the real type with
finfo, generate your own random file name, and store files outside the public web folder or in a folder where PHP can't run. - Errors: on a live site, set
display_errors=Offandlog_errors=On. Error messages can reveal file paths, SQL and settings to attackers. - Secrets: keep database passwords and API keys in environment variables or a
.envfile outside the web root, never in code committed to Git. - HTTPS: serve the whole site over HTTPS so passwords and cookies can't be read in transit.
PHP Security Syntax
Security toolkit
htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$stmt = $pdo->prepare('... WHERE id = ?'); $stmt->execute([$id]);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '');
session_set_cookie_params(['httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
session_regenerate_id(true);
(new finfo(FILEINFO_MIME_TYPE))->file($tmpPath);
ini_set('display_errors', '0');
getenv('DB_PASSWORD');PHP Security Summary
- Escape all output with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') to prevent XSS.
- Use prepared statements for every query containing input to prevent SQL injection.
- Protect forms that change data with CSRF tokens checked by hash_equals, plus SameSite cookies.
- Secure session cookies with HttpOnly, Secure and SameSite, and regenerate the id on login.
- Validate uploads, hide errors in production and keep secrets out of code.
PHP Security Examples
Each example shows the code, the output it produces and what each part does.
Escaping stops XSS
<?php
$comment = '<script>alert("Hacked")</script> Great post!';
$name = 'Sam "The Coder" <b>Lee</b>';
echo "<p>" . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . "</p>\n";
echo '<input value="' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '">' . "\n";<p><script>alert("Hacked")</script> Great post!</p>
<input value="Sam "The Coder" <b>Lee</b>">Line-by-line explanation
- Line 2A comment containing a script, as an attacker would submit it.
- Line 5After escaping, the browser shows the script as harmless text.
- Line 6ENT_QUOTES also escapes quotes, which matters inside attributes: an unescaped " would end the value early and let an attacker add their own attributes.
A CSRF-protected form
<?php
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Lax',
]);
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
function csrf_field(): string {
return '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">';
}
function check_csrf(): void {
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid form submission. Please reload the page and try again.');
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
// safe to change data here
}
?>
<form method="post" action="delete.php">
<?= csrf_field() ?>
<button>Delete my account</button>
</form>Your own form: includes the session's token, so check_csrf() passes.
A forged form on another site: has no valid token, so the request
gets a 403 response and nothing is changed.Line-by-line explanation
- Lines 2 to 6Secure cookie settings must be set before session_start.
- Line 8Create one random token per session; ??= only assigns if it isn't set yet.
- Line 11The token goes into every form as a hidden field.
- Line 15hash_equals compares in constant time, so the comparison doesn't leak timing clues.
- Lines 22 to 23Check the token before any change is made.
A safer file upload
<?php
$file = $_FILES['avatar'] ?? null;
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
exit('Upload failed.');
}
if ($file['size'] > 2 * 1024 * 1024) {
exit('Images must be 2 MB or smaller.');
}
$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($allowed[$type])) {
exit('Only JPEG, PNG and WebP images are allowed.');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$type];
move_uploaded_file($file['tmp_name'], __DIR__ . '/../storage/avatars/' . $name);
echo "Saved as $name";photo.jpg (1.4 MB, real JPEG) -> Saved as 3f9a1c0e7b2d4e8f9a0b1c2d3e4f5a6b.jpg
shell.php renamed to cat.jpg -> Only JPEG, PNG and WebP images are allowed.
huge.png (8 MB) -> Images must be 2 MB or smaller.Line-by-line explanation
- Line 8Check the size on the server; browser limits can be bypassed.
- Line 11finfo reads the file's actual contents to find its type, rather than trusting the name or the browser.
- Line 16A random name means users can't choose a name like index.php or overwrite other files.
- Line 17Store uploads outside the public folder, and serve them through a script or a folder where PHP can't run.
Common PHP Security Mistakes
Echoing user input directly
echo "Results for: " . $_GET['q'];echo "Results for: " . htmlspecialchars($_GET['q'] ?? '', ENT_QUOTES, 'UTF-8');Building SQL from strings
$pdo->query("SELECT * FROM orders WHERE id = " . $_GET['id']);$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$_GET['id']]);Showing errors on a live site
ini_set('display_errors', '1'); // visitors see paths, SQL and settingsini_set('display_errors', '0');
ini_set('log_errors', '1'); // errors go to the server log insteadPHP Security in Real Projects
Security in practice
The OWASP Top 10, a widely used list of web security risks, has featured injection and cross-site scripting for years. Frameworks like Laravel apply these defences by default; knowing what they protect against stops you switching them off by accident.
PHP Security FAQ
Is htmlspecialchars enough to prevent XSS?
It's the right tool for text placed into HTML content and quoted attribute values. Since PHP 8.1 it escapes both single and double quotes by default. Other contexts need other encoding: use json_encode for data inside JavaScript, urlencode for URL parameters, and validate URLs before placing them in href attributes.
What is a Content Security Policy?
A response header that tells the browser which sources of scripts, styles and images it may load, for example Content-Security-Policy: script-src 'self'. If an attacker manages to inject a <script> tag, the browser refuses to run it. It's a strong second layer of defence on top of output escaping.
How do I keep PHP and Composer packages secure?
Run a supported PHP version and apply its updates, and keep dependencies current. composer audit checks your installed packages against known security advisories. Subscribe to security announcements for your framework and update promptly when a fix is released.
How do I stop my site being shown inside another site's frame?
Send Content-Security-Policy: frame-ancestors 'self', or the older X-Frame-Options: SAMEORIGIN header. This prevents clickjacking, where an attacker shows your page in an invisible frame and tricks users into clicking buttons on it.
Do frameworks take care of security for me?
They help a lot: Laravel and Symfony escape template output, add CSRF tokens, use prepared statements and handle sessions safely by default. You can still create holes, for example by printing raw HTML, building SQL strings by hand or skipping authorisation checks, so the principles in this lesson still apply.
PHP Security Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the code so the user's name is printed safely in HTML.
echo ($name, ENT_QUOTES, 'UTF-8');
Which attack does escaping output with htmlspecialchars prevent?
What does a CSRF token protect against?
What does htmlspecialchars('<b>Hi</b>', ENT_QUOTES, 'UTF-8') return?
Why check an upload's type with finfo instead of its file name?
PHP Security Quiz
5 questions to check what you learned.
What should
display_errorsbe on a live website?- Off, with errors logged instead
- On
- It doesn't matter
- Only on Fridays
What does the HttpOnly cookie flag do?
- Makes the cookie HTTPS-only
- Deletes the cookie on close
- Stops JavaScript from reading the cookie
- Encrypts the cookie
Why compare CSRF tokens with hash_equals instead of ===?
- === doesn't work on strings
- hash_equals takes the same time whatever the input, so it doesn't leak clues through timing
- hash_equals is shorter
- Tokens are numbers
Where should database passwords live?
- In index.php
- In environment variables or a .env file outside the web root, not committed to Git
- In a public config.txt
- In the HTML source
Which describes "never trust input, always escape output"?
- Validate everything that comes in, and escape values in the right way for wherever they're sent
- Encrypt every value
- Block all users
- Use only GET requests