Himanshu SharmaSoftware engineer
Let’s talk

PHP · Lesson 53 of 54

Web security: XSS, CSRF and SQL injection

Defend PHP sites against XSS, CSRF and SQL injection with escaping, tokens and prepared statements, plus safe sessions, headers and redirects.

15 minDatabases and securityUpdated 22 October 2026

Awaiting technical review. This lesson is readable but not yet listed in search engines.

By the end of this lessonRecognise the common web attacks and apply the standard PHP defence for each one.

Thinking like an attacker

Every input to a PHP page, whether a URL, a form field, a cookie, a header or an uploaded file, is under the control of whoever sent the request. Security comes from a small set of habits applied every time. This lesson collects them in one place, with a working example of the defence most often missing: CSRF tokens.

The organisation OWASP publishes the "Top 10" most critical web application risks; the attacks below are long-standing members of it.

1. Cross-site scripting (XSS)

The attack: text from a visitor is printed into a page without escaping, so their HTML or JavaScript runs in other visitors' browsers. A script can read the page, act as the signed-in user, or steal cookies that are not HttpOnly.

The defence: escape on output, every time, for the context you print into. You met it in Mixing PHP and HTML:

php
<?php
function e(?string $value): string
{
    return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

$comment = '<img src=x onerror="alert(document.cookie)">';
echo '<p>' . e($comment) . "</p>\n";
echo '<input value="' . e('" autofocus onfocus="alert(1)') . "\">\n";
result
<p>&lt;img src=x onerror=&quot;alert(document.cookie)&quot;&gt;</p>
<input value="&quot; autofocus onfocus=&quot;alert(1)">

Escaping inside an attribute needs the quotes escaped too, which ENT_QUOTES does. Two contexts need extra care:

  • URLs in href or src: escaping does not stop javascript:alert(1). Only accept links that start with https:// or http:// (or a path starting with /).
  • Inside a script tag: pass data with json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT), never by printing raw values into JavaScript.

As a second layer, a Content-Security-Policy header tells the browser which scripts are allowed to run, so an injected inline script is blocked even if escaping was missed somewhere:

php
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'");

2. Cross-site request forgery (CSRF)

The attack: a visitor is signed in to your site. They open a page on another site, which contains a hidden form that posts to https://yoursite.com/account/delete.php and submits itself. The browser attaches your site's session cookie automatically, so your server sees a genuine signed-in request.

The defences:

  1. Never change data on a GET request. Links and images can trigger GETs from anywhere.
  2. Set session cookies with SameSite=Lax (PHP sessions can be configured this way, see Sessions), which stops most cross-site POSTs from carrying the cookie.
  3. Put a CSRF token in every form that changes data: a long random value stored in the session and included as a hidden field. Another site cannot read it, so it cannot include it.
php
<?php
function csrfToken(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function checkCsrf(): void
{
    $expected = $_SESSION['csrf'] ?? '';
    $sent = $_POST['csrf'] ?? '';
    // An empty expected token must fail: otherwise a new session ('') would match a missing field ('').
    if ($expected === '' || !is_string($sent) || !hash_equals($expected, $sent)) {
        http_response_code(403);
        exit("Your session expired or the form was not sent from this site. Please try again.");
    }
}
php
<?php
session_start();
require __DIR__ . '/csrf.php';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    checkCsrf();
    exit("Unsubscribed.");
}
?>
<form method="post">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars(csrfToken()) ?>">
  <button>Unsubscribe</button>
</form>

A forged request from another site arrives with the cookie but without the token:

http
POST /newsletter.php
Content-Type: application/x-www-form-urlencoded

confirm=1
result
HTTP/1.1 403 Forbidden
Set-Cookie: PHPSESSID=<random id>; path=/

Your session expired or the form was not sent from this site. Please try again.

The genuine page shows the form with the token in a hidden field. The real token is 64 random hexadecimal characters; it is shown here as {{csrf}}:

http
GET /newsletter.php
result
<form method="post">
  <input type="hidden" name="csrf" value="{{csrf}}">
  <button>Unsubscribe</button>
</form>

Submitting that form sends the token back with the session cookie, and the request is accepted:

http
POST /newsletter.php
Content-Type: application/x-www-form-urlencoded

csrf={{csrf}}
result
Unsubscribed.

hash_equals() compares the two strings in constant time, so an attacker cannot learn the token character by character by measuring response times.

Notice the $expected === '' check. An earlier draft of this lesson left it out, and running the forged request against it showed the problem: a brand-new visitor has no token in their session yet, the forged form sends none either, and hash_equals('', '') is true, so the forgery got through. Always make a missing token fail. Frameworks such as Laravel add and check these tokens for every form automatically.

3. SQL injection

The attack: input is glued into SQL and changes the query, as demonstrated in CRUD with prepared statements.

The defence: prepared statements with placeholders for every value, always, plus allowed lists for column names and sort directions. Give the application's database user only the privileges it needs.

4. Broken authentication and sessions

  • Hash passwords with password_hash() (Storing passwords safely).
  • Call session_regenerate_id(true) after login, and destroy the session on logout.
  • Session cookies: Secure, HttpOnly, SameSite=Lax; session.use_strict_mode=1.
  • Limit login attempts by account and IP address.
  • Check authorisation on every request, not just authentication: being signed in does not mean a user may see order 1042. Always query with the owner, WHERE id = ? AND user_id = ?.

5. Other everyday mistakes

Risk Defence
Error messages show paths and queries display_errors=Off in production, log instead (Errors and exceptions)
Uploaded scripts Check the real type, rename, store outside the web root (File uploads)
Including files by user input Never include $_GET['page']; map allowed names to files
Open redirects Redirect only to paths or hosts from an allowed list (Superglobals, headers and redirects)
Clickjacking header('X-Frame-Options: DENY') or CSP frame-ancestors 'none'
Secrets in code Environment variables or files outside the web root; never in a public repository
Plain HTTP HTTPS everywhere, with Strict-Transport-Security
Old dependencies Keep PHP updated; run composer audit

A small set of response headers worth sending on every page:

php
<?php
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header_remove('X-Powered-By');
echo "Secure headers sent";
http
GET /headers.php
result
Secure headers sent

X-Powered-By would otherwise advertise your exact PHP version; removing it (or setting expose_php=Off in php.ini) gives attackers one less clue.

Common mistakes

  • Escaping some values but not all. Make escaping the default, for example with a helper or a template engine.
  • Changing data on GET requests, which no CSRF token can protect.
  • Checking that a user is signed in but not that the record belongs to them.
  • Thinking a hidden form field or a disabled button is a security control. Anything in the browser can be changed.

Exercise

A page shows an order with SELECT * FROM orders WHERE id = $_GET[id], prints the customer's note with echo $order['note'], and has a "cancel" link to cancel.php?id=1042. List the three vulnerabilities and the fix for each.

Show one possible answer
  1. SQL injection, because the ID is glued into the SQL. Use a prepared statement, and add AND user_id = ? so people can only see their own orders, which also fixes the missing authorisation check.
  2. XSS, because the note is printed without escaping. Use htmlspecialchars() (or the e() helper).
  3. CSRF, because cancelling happens on a GET link. Make cancelling a POST form with a CSRF token, and check the token and the order's owner before cancelling.
php
<?php
$statement = $pdo->prepare('SELECT * FROM orders WHERE id = ? AND user_id = ?');
$statement->execute([$_GET['id'] ?? 0, $_SESSION['user_id']]);
$order = $statement->fetch() ?: exit('Order not found');
?>
<p><?= e($order['note']) ?></p>
<form method="post" action="/cancel.php">
  <input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
  <input type="hidden" name="id" value="<?= (int) $order['id'] ?>">
  <button>Cancel order</button>
</form>

Key points

  • XSS: escape every value printed into HTML with htmlspecialchars(), and use a Content-Security-Policy header as a second layer.
  • CSRF: give every state-changing form a secret per-session token and check it with hash_equals() before acting.
  • SQL injection: always use prepared statements; pick identifiers from allowed lists.
  • Also: HTTPS, secure session cookies, hidden error messages, safe uploads, allowed-list redirects and up-to-date dependencies.

Check yourself

1. What is cross-site request forgery (CSRF)?
2. Why compare CSRF tokens with hash_equals() instead of ===?
3. Which defence stops XSS when printing a comment into a page?
4. Which request methods need CSRF protection?