Software

PHP Application Security: Practical Defences Against SQL Injection and XSS

Prepared statements, context-aware output escaping, Content Security Policy and safe error handling: practical defences against the two most common attacks on PHP projects.

Injection attacks have sat near the top of the OWASP Top 10 for years. The good news: most SQL injection and XSS vulnerabilities can be eliminated with a few habits set up correctly from the start. This article summarises the core defences I use in PHP projects, with code examples.

SQL injection: user data never goes into the query text

The problem is concatenating user input into SQL:

// WRONG: typing ' OR '1'='1 into the email field changes the query
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";

The fix is prepared statements. The query and the data are sent to the database separately, so data is never interpreted as a command:

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

Where parameters can't be used: allowlists

Table names, column names and sort direction can't be bound as parameters. If these come from the user, accept only one of a predefined set of values:

$allowed = ['created_at', 'price', 'title'];
$column = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'created_at';
$stmt = $pdo->query("SELECT * FROM listings ORDER BY $column DESC");

Also give the application's database user only the privileges it needs; a web app has no reason to be able to drop tables.

XSS: escape data when you output it

XSS happens when content entered by a user runs as HTML or JavaScript on the page. The core rule: escape data not when you store it but when you output it, and in a way that fits the context.

function e(mixed $value): string
{
    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<h2><?= e($listing['title']) ?></h2>
<a href="/listing/<?= e(rawurlencode($listing['slug'])) ?>">Details</a>
  • ENT_QUOTES escapes single and double quotes too, which is essential inside attributes.
  • Before writing a user-supplied URL into a link, allow only the http and https schemes; javascript: URLs still run even when escaped.
  • If you need to pass data into JavaScript, use json_encode with the JSON_HEX_TAG and JSON_HEX_AMP flags.

If you accept rich text (HTML)

You can't escape fields where HTML is allowed, such as a blog editor; use a sanitiser that allowlists permitted tags and attributes instead (for example HTML Purifier). script, on* event attributes and style should never get through.

A second line of defence: Content Security Policy

Even if escaping is forgotten somewhere, CSP makes the browser run scripts only from sources you allow. For a site without inline scripts, this header is a strong starting point:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';
  base-uri 'self'; frame-ancestors 'none'; form-action 'self'

Marking session cookies HttpOnly, Secure and SameSite=Lax also makes it harder for a potential XSS to turn into session theft.

Complementary measures

  • CSRF protection: validate an unpredictable, session-bound token on every state-changing form request.
  • Rate limiting: limit attempts per IP or session on login, password reset and contact forms.
  • Error handling: keep display_errors off in production; write detailed errors to the server log and show users only a generic message. Directory paths and table names must never leak.
  • Secrets: keep database passwords and API keys out of the code, in a .env file outside the web root.
  • Updates: keep PHP and dependencies up to date; composer audit lists known vulnerabilities.

Summary

Prepared statements and allowlists for SQL; context-aware escaping and a sanitiser for HTML; CSP and secure cookies for the browser. Applied together, these three layers stop most common attacks at the door. Security isn't a feature you add later; it's a way of writing code from the first line.