Skip to content
beginner Phase 10 · Securing Web Applications

Validating and Sanitizing Input

Whitelist accepted data before it reaches your logic.

40m
0 problems
Topic Progress 0%

Blocklists Fail on the First Novel Payload: State What You Accept

Here is the defense you write when you are thinking about what to block:

if (preg_match('/<script|onerror|onload|javascript:/i', $_POST['comment'])) {
    exit('Malformed comment');
}

It works right up to the payload that was written yesterday: <svg/onload=alert(1)> is clean if you only listed onerror, <details open ontoggle=alert(1)> skips both, <body onfocus=this.focus() tabindex=1> uses onfocus, <iframe srcdoc="<script>alert(1)</script>"> nests the tag you did filter inside an attribute you did not. A denylist is a bet that you have already imagined every hostile input — and the attacker's job is only to find one you missed. The vocabulary is open-ended: event handlers alone number more than seventy, encodings multiply them, and every new HTML feature adds more.

Flip the question. Instead of listing what is forbidden, state what you accept:

if (!preg_match('\A[a-z0-9_]{3,20}\z', $username)) {
    http_response_code(422);
    exit('Username must be 3-20 chars: a-z, 0-9, underscore');
}

An allowlist fails closed: input outside the grammar is rejected whether or not you have heard of it, and <svg/onload=alert(1)> never needs to be in a rule anywhere. Two details decide whether your allowlist is real:

  • Anchor it. preg_match('/[a-z0-9]+/', $v) matches "!!!admin!!!" because a substring satisfies it. Use \A … \z, not ^ … $ — the $ anchor also matches before a trailing newline, so "abc\n" passes /^[a-z]+$/ while \A[a-z]+\z rejects it.
  • Compare strictly. in_array($role, ['user', 'admin']) without true juggles types: "admin" == 0, null == "", and "1e2" == 100 all compare equal in loose mode, so an attacker sends role=0 or a scientific-notation string and walks past the gate. in_array($role, ['user', 'admin'], true) compares value and type.

Fields map cleanly to rules, and the rule is documentation you can point a reviewer at:

Field Allowlist rule Example it rejects
username \A[a-z0-9_]{3,20}\z admin; DROP, <script>
slug \A[a-z0-9]+(?:-[a-z0-9]+)*\z ../etc/passwd, a b
e-mail filter_var(..., FILTER_VALIDATE_EMAIL) not-an-email, a@b
quantity filter_var(..., FILTER_VALIDATE_INT) + min_range: 1 0, -5, 1 OR 1=1
role in_array($v, ['user','admin'], true) superadmin, 1
date DateTime::createFromFormat('!Y-m-d', $v) + getLastErrors() 2026-02-31

The three verbs people use as synonyms — validate, sanitize, escape — belong to three different moments, and collapsing them is exactly how invalid data survives review:

Verb The question it answers Example Where it happens
Validate Does this match the shape I expect? filter_var($v, FILTER_VALIDATE_EMAIL) — reject on false request boundary, before anything stores it
Sanitize Can I losslessly reshape this into something harmless? FILTER_SANITIZE_NUMBER_INT turns a1b2 into 12 almost never — it forgives input you meant to refuse
Escape How must these bytes be encoded for this destination? htmlspecialchars($v, ENT_QUOTES, 'UTF-8') at the echo, per context — see Escaping Output to Prevent XSS

The sanitizer trap deserves its own warning: a silent rewrite lets invalid data through your logic wearing a valid costume. a1b2 → 12 sails past a min_range check the original input would have failed, and no error ever announces that it happened. PHP's own history backs the verdict — FILTER_SANITIZE_STRING was deprecated in 8.1 and removed in 8.2, because stripping tags is neither validation (the input should have been rejected outright) nor escaping (only the output context knows the right encoding).

Normalize before validating (trim(), lowercase for case-insensitive fields) and accept that invalid input dies here, before it reaches a query, a view, or a log line — that early rejection is what the next chapter builds on with PHP's own filter functions, and it is the same discipline CSRF Tokens for Forms applies to requests: one gate, fail closed, before any handler runs.

filter_input and filter_var: Typed Checks and the null / false / value Semantics

PHP ships typed validation for request data, and the type system of the filter API is where candidates lose points. Start with the function that reads the original request:

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1, 'max_range' => 100000],
]);

if ($id === false || $id === null) {          // invalid, or not present at all
    http_response_code(422);
    exit('id must be an integer between 1 and 100000');
}

Three return meanings, distinguishable only with ===:

Situation Returned Never confuse with
variable absent from the request null false (that means present-but-wrong)
present, fails the filter false 0 or '' (valid values a loose check would drop)
present, valid the value, typed by the filter —

The absent-versus-invalid split is the interview question: filter_input(INPUT_POST, 'page') on a request with no page returns null, so if (!filter_input(...)) collapses "missing" and "broken" into one branch — fine when both should 422, wrong when a missing field should default to 10 and an invalid one should be rejected. filter_var() behaves the same way but reads a value you already hold; the difference matters when code elsewhere has mutated $_GET/$_POST — filter_input still sees what actually arrived on the wire.

Bulk validation keeps the shapes together:

$input = filter_input_array(INPUT_POST, [
    'email' => FILTER_VALIDATE_EMAIL,
    'qty'   => FILTER_VALIDATE_INT,
    'plan'  => ['filter' => FILTER_VALIDATE_REGEXP,
                'options' => ['regexp' => '/\A(pro|free)\z/']],
    'tags'  => ['filter' => FILTER_SANITIZE_FULL_SPECIAL_CHARS,
                'flags'   => FILTER_REQUIRE_ARRAY],
]);
if ($input === false || in_array(false, $input, true)) {
    http_response_code(422); exit('One or more fields are invalid');
}

The filters worth knowing by name:

Filter Accepts Watch out
FILTER_VALIDATE_INT integers, optional min_range/max_range needs 'options', not bare numbers
FILTER_VALIDATE_EMAIL syntactically valid addresses syntax ≠ deliverability ≠ ownership
FILTER_VALIDATE_URL URL-shaped strings accepts schemes you never wanted — allowlist the scheme separately
FILTER_VALIDATE_IP with FILTER_FLAG_IPV4 / IPV6 unset flags accept both families
FILTER_VALIDATE_REGEXP against your anchored pattern it is your pattern; anchors are still your job
FILTER_VALIDATE_BOOL 1/0/true/false/on/off/yes/no aliases FILTER_VALIDATE_BOOLEAN (PHP 8)
FILTER_SANITIZE_FULL_SPECIAL_CHARS HTML-encodes & " ' < > it is htmlspecialchars for output, not validation
FILTER_SANITIZE_NUMBER_INT strips everything but digits and +- a sanitizer that deletes characters accepts input you meant to reject — a1b2 becomes 12 instead of failing

That last row is the whole philosophy: sanitizers transform, validators decide. FILTER_SANITIZE_STRING was deprecated in PHP 8.1 and removed in 8.2 precisely because stripping tags produced strings nobody had actually validated — output escaping (see Escaping Output to Prevent XSS) belongs at the echo, and rejection belongs at the boundary. Validation placed there means invalid data never reaches a query, a view, or your logs, which is what the next chapter pressure-tests with JSON bodies, arrays pretending to be scalars, and uploaded files.

Reject at the Boundary: Array Parameters, JSON Bodies, and Uploads That Lie About Their Type

Validation earns its keep at the boundary — the front controller or route handler — and three input shapes are designed to slip past naive checks.

1. Arrays where you expected scalars. PHP happily parses ?id[]=1, ?id[foo]=bar, and role=admin&role[]=x:

$id = $_GET['id'];                     // int 42 or array — you don't get to choose
var_dump((int) ['1 OR 1=1']);          // int 1: a non-empty array casts to 1

Your cast "works", the query runs against id = 1 for every request, and production silently returns the wrong row instead of failing. Check the shape before the value:

if (!isset($_GET['id']) || !is_scalar($_GET['id'])) {
    http_response_code(422); exit('id must be a scalar');
}
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, FILTER_REQUIRE_SCALAR);
if ($id === false || $id === null) { http_response_code(422); exit('bad id'); }

FILTER_REQUIRE_SCALAR plus is_scalar() closes the door that preg_match alone leaves open — in PHP 8, passing an array to preg_match() throws a TypeError, so the shape check must run first or your 422 becomes a 500 with a stack trace.

2. JSON bodies. filter_input() reads form-encoded GET/POST/cookie data, not php://input, so a JSON endpoint validates the decoded structure itself:

try {
    $data = json_decode(file_get_contents('php://input'), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    http_response_code(400); exit('Body must be valid JSON');
}
if (!is_array($data)) { http_response_code(422); exit('Expected a JSON object'); }

$email = filter_var($data['email'] ?? null, FILTER_VALIDATE_EMAIL);
$role  = in_array($data['role'] ?? '', ['user', 'admin'], true) ? $data['role'] : null;
if ($email === false || $role === null) { http_response_code(422); exit('email or role invalid'); }

Note ?? null before filtering: $data['email'] on a missing key is a notice and an undefined value, not a validation failure. Depth-limited json_decode (that 8) keeps a hostile payload from eating memory, and JSON_THROW_ON_ERROR turns "silently return null" into a catchable failure — json_last_error() checked after some other call is the classic race in review.

3. Files that lie about their type. $_FILES['avatar']['type'] is the client's claim — send Content-Type: image/png on a PHP shell and it says image/png:

$f = $_FILES['avatar'] ?? null;
if (!$f || $f['error'] !== UPLOAD_ERR_OK || $f['size'] > 2 * 1024 * 1024) { http_response_code(422); exit('bad upload'); }
if (!is_uploaded_file($f['tmp_name'])) { http_response_code(422); exit('not a real upload'); }

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);   // sniffed bytes, not the header
$allow = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!isset($allow[$mime])) { http_response_code(422); exit('JPEG or PNG only'); }
$name = bin2hex(random_bytes(16)) . '.' . $allow[$mime];         // your name, never theirs
move_uploaded_file($f['tmp_name'], '/var/www/uploads/' . $name);

Extension, declared MIME, and sniffed MIME must all agree with your allowlist, and the stored name must be one you generated — a user-supplied filename is a path traversal (../../) and a stored XSS (x.html) in one string.

Two boundary rules finish the job. Client-side validation is courtesy, not control — curl deletes your JavaScript and replays the request, so every rule must exist server-side too. And reject before logging: a failed-validation log line should record the field name and the rule violated, not the raw password attempt or the full PII payload, or your logs become the leak you were defending against. That is the complete loop — reject at the gate, keep what passes typed and narrow — and the credentials that survive it get hashed in the next topic, Hashing Passwords Correctly.

Quiz

1. A comment filter uses preg_match('/<script|onerror|onload/i', $input) and rejects on match. Which payload gets through without changing the words in the rule?

Question 1 options

2. filter_input(INPUT_GET, 'page') returns null in one request and false in another. What is the difference?

Question 2 options

3. Your route reads $id = (int) $_GET['id'] and a request arrives as ?id[]=7. What happens?

Question 3 options

4. An upload endpoint trusts $_FILES['avatar']['type'] === 'image/png' and stores the file under its original name. Which pair of failures does that allow?

Question 4 options

Flashcards

Question

Why must an allowlist regex be anchored, and which anchors are safe in PHP?

Answer

Unanchored patterns match substrings, so '!!!admin!!!' passes /[a-z0-9]+/. Use \A … \z (or the D modifier with ^ $): $ matches before a trailing newline, so /^[a-z]+$/ accepts 'abc\n' while \A[a-z]+\z rejects it. Allowlist fails closed; denylist only covers payloads you already imagined.

Question

What are filter_input's three return meanings and why does it matter?

Answer

null = variable absent, false = present but invalid, value = valid (0 and '' are valid values). Check with ===: if (!filter_input(...)) collapses missing and malformed, which breaks the common 'missing → default 10, malformed → 422' behavior. filter_input also reads the original wire value even if code mutated $_GET.

Question

Sanitizer or validator — what is the difference and which belongs at the boundary?

Answer

A validator decides and rejects (FILTER_VALIDATE_*); a sanitizer transforms (FILTER_SANITIZE_*) — FILTER_SANITIZE_NUMBER_INT turns 'a1b2' into '12' instead of failing, silently accepting input you meant to refuse. Reject at the boundary; HTML-encode at the echo with htmlspecialchars, which is why FILTER_SANITIZE_STRING (deprecated 8.1, removed 8.2) disappeared.

Question

How do you validate a JSON request body in PHP?

Answer

json_decode(file_get_contents('php://input'), true, 8, JSON_THROW_ON_ERROR) inside try/catch — JsonException → 400; then check is_array and filter_var each field with ?? null before filtering. filter_input does not see php://input, and depth-limiting the decode keeps hostile payloads from exhausting memory.

Question

What makes a file upload validation trustworthy?

Answer

Check upload error code and size, confirm with is_uploaded_file(), sniff real bytes via finfo FILEINFO_MIME_TYPE against a MIME allowlist (never trust $_FILES['type'], which is the client's claim), require extension/MIME agreement, and store under a name you generated — the client's filename is path traversal plus stored XSS.

Revision Notes

Key Takeaways

  • 1. State what you accept: an anchored \A…\z allowlist fails closed against payloads that do not exist yet, while any denylist is missing the next one
  • 2. filter_input returns null for absent and false for invalid — the === distinction that separates 'use the default' from 'reject', and 0/'' are valid values
  • 3. Sanitizers transform, validators decide; FILTER_SANITIZE_NUMBER_INT accepting 'a1b2' as '12' is the anti-pattern, and output encoding belongs at the echo
  • 4. Attack the shape before the value: is_scalar/FILTER_REQUIRE_SCALAR for ?id[]=1, JSON_THROW_ON_ERROR for bodies, finfo + allowlist for uploads
  • 5. Validation lives at the request boundary so garbage never reaches a query, a view, or a log line; browser-side checks are UX, never control

Interview Tips

  • • "How do you validate a username?" — anchored allowlist with \A[a-z0-9_]{3,20}\z, trim first, then explain why /…/ without anchors and == comparisons both fail; naming the $ trailing-newline quirk signals real regex experience
  • • "What is the difference between filter_var and filter_input?" — source (a value you hold vs what arrived on the wire) plus the null/false/valid triad; mention that mutating $_GET does not change what filter_input reports
  • • "Client-side validation exists — is that enough?" — no: curl deletes your JavaScript; server-side rules are the control and the browser check is only feedback for the user
  • • "Sanitize or validate user input?" — validate and reject at the boundary; sanitizing hides bad input (number sanitizer) or destroys data (strip tags), and HTML encoding is an output concern, per FILTER_SANITIZE_STRING's removal in 8.2

Cheat Sheet

Validating and Sanitizing Input

Allowlist, not denylist:

if (!preg_match('\A[a-z0-9_]{3,20}\z', $username)) { http_response_code(422); exit('bad username'); }
  • Anchor with \A … \z — ^…$ also matches before a trailing newline; unanchored patterns match substrings ("!!!admin!!!").
  • Compare with === / in_array($v, $list, true) — loose == invites 0 == "admin", "1e2" == 100.
  • Denylists fail closed against your imagination only: <svg/onload>, <details ontoggle>, <body onfocus>… the vocabulary grows, your list doesn't.
  • Normalize first (trim, lowercase), validate second, reject third — before query, view, or log.

filter_input semantics (the === table):

Situation Returns
variable absent null
present, invalid false
present, valid typed value (0 / '' are valid — never use truthiness)

filter_input reads what actually arrived on the wire (unaffected by code that mutated $_GET); filter_var filters a value you already hold.

Filter vocabulary:

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT,
                   ['options' => ['min_range' => 1, 'max_range' => 100000]]);

$input = filter_input_array(INPUT_POST, [
    'email' => FILTER_VALIDATE_EMAIL,
    'plan'  => ['filter' => FILTER_VALIDATE_REGEXP, 'options' => ['regexp' => '/\A(pro|free)\z/']],
    'tags'  => ['filter' => FILTER_SANITIZE_FULL_SPECIAL_CHARS, 'flags' => FILTER_REQUIRE_ARRAY],
]);
Filter Note
FILTER_VALIDATE_URL scheme allowlist separately — it accepts javascript:-ish shapes you don't want
FILTER_VALIDATE_BOOL 1/0/on/off/yes/no/true/false (alias FILTER_VALIDATE_BOOLEAN)
FILTER_SANITIZE_FULL_SPECIAL_CHARS that's output escaping — belongs at the echo
FILTER_SANITIZE_NUMBER_INT deletes bad chars (a1b2 → 12) instead of rejecting — not validation
FILTER_SANITIZE_STRING deprecated 8.1, removed 8.2 — never

Boundary shapes that slip past naive checks:

  • Scalars: ?id[]=1 → is_scalar() / FILTER_REQUIRE_SCALAR first (PHP 8 preg_match on an array throws TypeError); (int) ['…'] === 1 — wrong rows, no error.
  • JSON: json_decode(file_get_contents('php://input'), true, 8, JSON_THROW_ON_ERROR) → catch JsonException → 400, then filter_var each field (?? null before filtering).
  • Uploads: is_uploaded_file() + finfo sniffed MIME against an allowlist + size cap + generated store name; never trust $_FILES['type'] or the client's filename.

Rules: client-side validation is UX only (replay with curl); reject before logging so failed attempts don't dump passwords/PII into logs; 422 with the rule violated, 400 for malformed JSON.

OWASP framing: Input Validation Cheat Sheet — allowlist what is expected, validate at the boundary, keep validation and sanitization as separate jobs.

Continue Learning

Before You Start

Related Reading