PHP Input Sanitization

Every piece of data that enters a PHP script from outside — a form field, a URL query string, a cookie, an uploaded file name, even an HTTP header — is untrusted until proven otherwise. Input sanitization is the process of cleaning, normalizing, or rejecting that data so it cannot be used to attack your application. It is one half of PHP’s most important security habit: sanitize/validate on input, escape on output. Get this wrong and you open the door to cross-site scripting (XSS), SQL injection, header injection, and worse.

Overview: How Input Sanitization Works

PHP populates several "superglobal" arrays with request data: $_GET, $_POST, $_COOKIE, $_REQUEST, $_SERVER, and $_FILES. Internally, the Zend engine treats every one of these values as a plain PHP string (or an array of strings), regardless of what the client intended. There is no such thing as a "trusted integer" coming from $_GET['id'] — it is the string "5", or worse, it could be the string "5 OR 1=1" if someone tampers with the URL. PHP will never stop you from concatenating that string directly into HTML, a SQL query, or a shell command — that responsibility belongs entirely to you as the developer.

"Sanitization" and "validation" are related but different: validation asks "is this data in the shape I expect?" and returns true/false (or throws), while sanitization transforms data into a safer form, stripping or encoding characters that could be dangerous in a particular context. Neither one alone is a silver bullet. Validating that an email address is well-formed doesn’t stop that same string from being echoed unsafely into HTML later; sanitizing a string for HTML output doesn’t make it safe to drop into a SQL query. Context matters — a string that is "safe" for a database query needs different treatment than one that is "safe" for an HTML attribute, a URL, or a shell command.

PHP’s built-in tool for this is the Filter extension (ext/filter), which ships enabled by default and exposes functions like filter_var(), filter_input(), and filter_var_array(). Alongside it, output-encoding functions like htmlspecialchars() and htmlentities() handle the "escape on output" half of the equation. Understanding both halves — and using them at the right point in your code — is the core skill this lesson teaches.

Syntax

The two workhorse functions look like this:

filter_var(mixed $value, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed
filter_input(int $type, string $var_name, int $filter = FILTER_DEFAULT, array|int $options = 0): mixed
Part Meaning
$value The raw data to check (any scalar or array, for filter_var).
$type For filter_input() only: INPUT_GET, INPUT_POST, INPUT_COOKIE, INPUT_SERVER, or INPUT_ENV — reads straight from the request, bypassing local reassignment of superglobals.
$filter A FILTER_VALIDATE_* constant (returns the cleaned value on success or false on failure) or a FILTER_SANITIZE_* constant (always returns a best-effort cleaned string).
$options Either bitwise flags (e.g. FILTER_NULL_ON_FAILURE) or an associative array with an options key holding filter-specific settings such as min_range, max_range, and default.

Common filter constants you will reach for constantly:

Constant Purpose
FILTER_VALIDATE_EMAIL Confirms a string is a syntactically valid email address.
FILTER_VALIDATE_INT Confirms a string is a whole number, optionally within a range.
FILTER_VALIDATE_URL Confirms a string is a syntactically valid URL.
FILTER_VALIDATE_BOOLEAN Converts "1"/"true"/"on"/"yes" to true, others to false or null.
FILTER_SANITIZE_SPECIAL_CHARS HTML-encodes <, >, &, ", ' and low ASCII control characters.
FILTER_SANITIZE_EMAIL Strips characters illegal in an email address.
FILTER_SANITIZE_URL Strips characters illegal in a URL (but keep reading — see Common Mistakes).
FILTER_SANITIZE_NUMBER_INT Strips everything except digits, + and -.

Note: FILTER_SANITIZE_STRING was deprecated in PHP 8.1 and removed entirely in PHP 9 — don’t use it in new code. Use FILTER_SANITIZE_SPECIAL_CHARS or, better, escape explicitly with htmlspecialchars() at output time.

Examples

Example 1: Cleaning a name and validating an email

<?php
$_POST = [
    'name'  => "  <b>John O'Malley</b>  ",
    'email' => ' JOHN.OMALLEY@Example.COM ',
];

$name  = trim($_POST['name']);
$name  = strip_tags($name);
$email = filter_var(trim($_POST['email']), FILTER_SANITIZE_EMAIL);

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo "Clean name: {$name}\n";
    echo "Valid email: {$email}\n";
} else {
    echo "Invalid email address.\n";
}

Output:

Clean name: John O'Malley
Valid email: JOHN.OMALLEY@Example.COM

Here strip_tags() removes the surrounding <b> markup (it does not remove text content, only tag markup, which matters when tags wrap dangerous scripts — more on that below). FILTER_SANITIZE_EMAIL strips characters that can’t legally appear in an email address, and FILTER_VALIDATE_EMAIL then confirms the result is well-formed before we trust it.

Example 2: Validating an integer with a safe default and range

<?php
$options = [
    'options' => [
        'default'   => 1,
        'min_range' => 1,
        'max_range' => 100,
    ],
];

foreach (['7abc', '250', '42'] as $rawPage) {
    $page = filter_var($rawPage, FILTER_VALIDATE_INT, $options);
    echo "Requested page: {$page}\n";
}

Output:

Requested page: 1
Requested page: 1
Requested page: 42

This is exactly the pattern you want for a pagination parameter like ?page=. "7abc" is not a well-formed integer string, so validation fails and the default of 1 is returned. "250" is a valid integer but exceeds max_range, so it also falls back to the default. Only "42" passes both the format check and the range check. Notice that the fallback never crashes the script or lets an out-of-range value reach your database query.

Example 3: Sanitizing a whole form and escaping for output

<?php
$_POST = [
    'username' => '  Alice_99  ',
    'website'  => 'https://example.com/"><script>evil()</script>',
    'comment'  => 'I <3 PHP & "modern" syntax!',
];

$filters = [
    'username' => FILTER_SANITIZE_SPECIAL_CHARS,
    'website'  => FILTER_SANITIZE_URL,
    'comment'  => FILTER_UNSAFE_RAW,
];

$clean = filter_var_array($_POST, $filters);
$clean['username'] = trim($clean['username']);

$safeComment = htmlspecialchars(trim($clean['comment']), ENT_QUOTES, 'UTF-8');
$safeWebsite = htmlspecialchars($clean['website'], ENT_QUOTES, 'UTF-8');

echo "Username: {$clean['username']}\n";
echo "Website: {$safeWebsite}\n";
echo "Comment: {$safeComment}\n";

Output:

Username: Alice_99
Website: https://example.com/&quot;&gt;&lt;script&gt;evil()&lt;/script&gt;
Comment: I &lt;3 PHP &amp; &quot;modern&quot; syntax!

This example deliberately proves a point: FILTER_SANITIZE_URL only strips characters that are never allowed in a URL — but its allowed character set (inherited from historic URL grammar) still permits <, >, and " to pass through untouched! The malicious <script> fragment survives the "sanitized" website value completely intact. It only becomes safe once htmlspecialchars() encodes it right before it is echoed. This is the whole lesson in miniature: sanitizing input is not the same as making output safe.

Under the Hood

The Filter extension is implemented in C as part of PHP core, not as regular expressions written in userland. Each FILTER_VALIDATE_* constant maps to a dedicated validator function compiled into the engine — for example, the email validator implements a state machine that walks the string character by character checking it against a simplified version of the RFC 5322 address grammar, rather than relying on a single fragile regex. This is why filter_var() is both fast and far more reliable than a hand-rolled regular expression.

A crucial distinction to internalize: FILTER_VALIDATE_* filters return the value unchanged on success or false on failure (you can switch failure to null with the FILTER_NULL_ON_FAILURE flag, which matters if "0" or an empty string is a legitimately valid value that would otherwise be confused with boolean false). FILTER_SANITIZE_* filters, by contrast, always return a string — there is no such thing as a "failed" sanitize call. That means a sanitize filter can never tell you whether the original data was well-formed; it only tells you what remains after stripping or encoding disallowed characters. Treating a sanitized value as if it had been validated is a very common source of bugs.

On the output side, htmlspecialchars() walks the string and replaces &, <, >, and (with ENT_QUOTES) both " and ' with their HTML entity equivalents (&amp;, &lt;, &gt;, &quot;, &#039;). As of PHP 8.1, the default flags changed to ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, so modern code no longer needs to pass ENT_QUOTES explicitly — but doing so anyway makes the intent explicit and keeps the code portable to codebases that still support older PHP versions. Always pass the charset ('UTF-8') explicitly too, since a mismatched charset assumption is itself a historic source of encoding-based XSS bypasses.

Common Mistakes

Mistake 1: Using addslashes() instead of prepared statements

Developers sometimes reach for addslashes() to "sanitize" a value before building a SQL string. This is fragile: it does not account for the target database’s actual escaping rules, multi-byte encoding tricks, or the query’s structural context, and several classic SQL-injection bypasses exploit exactly this gap.

<?php
$username = $_POST['username'] ?? '';
$safeUsername = addslashes($username);

$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$query = "SELECT id, email FROM users WHERE username = '{$safeUsername}'";
$result = $pdo->query($query);

The fix is to never build queries by string interpolation at all. Use a prepared statement with bound parameters — the database driver then handles escaping correctly for that exact context, and user input can never change the query’s structure:

<?php
$username = $_POST['username'] ?? '';

$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE username = :username');
$stmt->execute(['username' => $username]);
$result = $stmt->fetch();

Mistake 2: Escaping on the way in instead of the way out

A subtler mistake is calling htmlspecialchars() before storing data (e.g. in a database) rather than at the moment it is rendered. This corrupts the stored data and, if the same escaping is later applied again on output, produces garbled double-encoded text like &amp;amp;:

<?php
$comment = $_POST['comment'] ?? '';
$storedComment = htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');

$fromDatabase = $storedComment;
echo htmlspecialchars($fromDatabase, ENT_QUOTES, 'UTF-8');

Store the raw (merely trimmed/validated) value, and apply htmlspecialchars() exactly once, at the point where it is actually written into HTML:

<?php
$comment = trim($_POST['comment'] ?? '');

$rawCommentToStore = $comment;

echo htmlspecialchars($rawCommentToStore, ENT_QUOTES, 'UTF-8');

Best Practices

  • Follow the rule validate on input, escape on output — they are not interchangeable steps.
  • Always use context-appropriate escaping: htmlspecialchars() for HTML body text, attribute-safe encoding for HTML attributes, rawurlencode() for URL components, prepared statements for SQL, and escapeshellarg() for shell arguments.
  • Never build SQL by string concatenation — use PDO or mysqli with bound/prepared parameters, always.
  • Prefer a strict whitelist validation approach (reject anything that doesn’t match the expected format) over trying to sanitize away every possible bad character.
  • Use filter_var()/filter_input() with explicit min_range/max_range/default options for numeric input instead of casting with (int), which silently coerces malformed strings to 0.
  • Avoid the deprecated FILTER_SANITIZE_STRING; use FILTER_SANITIZE_SPECIAL_CHARS or explicit htmlspecialchars() calls instead.
  • Never trust client-side (JavaScript) validation as a security control — it is a convenience for users, not a defense.
  • Add a Content-Security-Policy header as defense in depth against XSS, even when output encoding is done correctly everywhere.
  • Sanitize file uploads separately: check the real MIME type and extension, generate a new filename, and never trust $_FILES['...']['name'] directly.

Practice Exercises

Exercise 1: Write a script that simulates $_POST with a username and an age field. Sanitize the username with FILTER_SANITIZE_SPECIAL_CHARS and trim it, then validate the age as an integer between 13 and 120 using filter_var() with a sensible default. Print both cleaned values.

Exercise 2: Given a multi-line comment string that may contain HTML and stray angle brackets, write code that safely renders it into an HTML page while still showing line breaks to the reader. Hint: think carefully about whether htmlspecialchars() or nl2br() should run first, and why.

Exercise 3: Take a snippet of code that builds a SQL WHERE clause with addslashes() and string concatenation, and rewrite it as a PDO prepared statement using named placeholders.

Summary

  • All request data ($_GET, $_POST, $_COOKIE, etc.) arrives as untrusted strings — PHP applies no automatic safety.
  • FILTER_VALIDATE_* filters check shape and return the value or false/null; FILTER_SANITIZE_* filters transform data but never confirm it was well-formed.
  • filter_var(), filter_input(), and filter_var_array() are the standard, engine-level tools for this work — prefer them over hand-written regex.
  • FILTER_SANITIZE_URL still allows <, >, and " through — sanitizing input never replaces escaping on output.
  • Escape exactly once, at the point of output, using the function appropriate to that context (htmlspecialchars() for HTML, prepared statements for SQL, etc.).
  • Never build SQL queries or shell commands via string concatenation with user input.
  • FILTER_SANITIZE_STRING is deprecated — use FILTER_SANITIZE_SPECIAL_CHARS or explicit escaping instead.