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]+\zrejects it. - Compare strictly.
in_array($role, ['user', 'admin'])withouttruejuggles types:"admin" == 0,null == "", and"1e2" == 100all compare equal in loose mode, so an attacker sendsrole=0or 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 |
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?
2. filter_input(INPUT_GET, 'page') returns null in one request and false in another. What is the difference?
3. Your route reads $id = (int) $_GET['id'] and a request arrives as ?id[]=7. What happens?
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?
Flashcards
Question
Why must an allowlist regex be anchored, and which anchors are safe in PHP?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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==invites0 == "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_SCALARfirst (PHP 8preg_matchon an array throwsTypeError);(int) ['…'] === 1— wrong rows, no error. - JSON:
json_decode(file_get_contents('php://input'), true, 8, JSON_THROW_ON_ERROR)→ catchJsonException→ 400, thenfilter_vareach field (?? nullbefore filtering). - Uploads:
is_uploaded_file()+finfosniffed 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.