---
title: 'Input Validation and Sanitization'
source: 'https://academia.sh/en/courses/php-web/input-validation-and-sanitization'
course: 'PHP on the Web: State, Data and Security'
language: en
updated: '2026-10-21T05:03:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Validates an input with filter_var() and range options, and shows that an invalid format returns false'
    - 'Separates validation from sanitization and measures that sanitization silently turns a malformed input into another value'
    - 'Explains with the party measurement that validation does not take the result away from the client and only narrows it to the valid set'
---

# Input Validation and Sanitization

Narrowing a client input to the valid set with filter_var(), and measuring that validation and sanitization are separate steps and that validation does not take the power to change the value away from the client.

In The HTTP Client lesson, a timeout exception bounded the result when an external service's response was slow, but the price still stayed in the third party's hands. The security topic turns the same question to the data that reaches the application directly: a value that arrives as a query parameter, a form field or a cookie is something the client sent. **Is this input valid, and if it is valid, who can change the result?**

**SE1.** Throughout this topic, the client input is a string constant; no real request is built. Invalid input is shown with neutral format errors (`'4x'`, `''`, `'alice@'`), not with strings of special characters. The code that runs is on the defending side. In the advanced course of the Rust curriculum, The Rust Ecosystem and Advanced Topics, we measured where a guarantee ends at a check; here the question is not the check itself but who is left holding the result.

## Validation: The Valid Set

**Input validation** decides whether an incoming value has the expected format and falls in the expected range, and rejects the value if it does not. In PHP the built-in tool for this is `filter_var()`: its second argument is a filter constant and its third is an options array. `FILTER_VALIDATE_INT` checks the integer format, and the `min_range` and `max_range` options check the range; on a matching input it returns the integer, and on a non-matching one it returns `false`. `FILTER_VALIDATE_EMAIL` tests the email format with the same contract.

Two small validators are used throughout the lesson. The underscored constant names follow PHP convention; the file is only written to disk, and the measurement files pull it in.

```php
<?php
// input.php — quantity and email validators
declare(strict_types=1);

const QUANTITY_OPTIONS = ['options' => ['min_range' => 1, 'max_range' => 10]];

function validateQuantity(string $input): int|false {
    return filter_var($input, FILTER_VALIDATE_INT, QUANTITY_OPTIONS);
}

function validateEmail(string $input): string|false {
    return filter_var($input, FILTER_VALIDATE_EMAIL);
}
```

An order quantity must be between 1 and 10. The measurement below passes four quantity inputs and two email inputs through the validators, then tries every integer from 0 to 12 and counts how many are accepted.

```php
<?php
// validate.php — valid and invalid formats
declare(strict_types=1);
require 'input.php';

foreach (['4', '4x', '', '11'] as $input) {
    echo 'quantity ', var_export($input, true), ' → ', var_export(validateQuantity($input), true), "\n";
}
foreach (['alice@example.test', 'alice@'] as $input) {
    echo 'email ', var_export($input, true), ' → ', var_export(validateEmail($input), true), "\n";
}
$accepted = array_filter(range(0, 12), fn(int $n) => validateQuantity((string) $n) !== false);
echo count($accepted), ' values accepted: from ', min($accepted), ' to ', max($accepted), "\n";
```

```
quantity '4' → 4
quantity '4x' → false
quantity '' → false
quantity '11' → false
email 'alice@example.test' → 'alice@example.test'
email 'alice@' → false
10 values accepted: from 1 to 10
```

A valid format passes by converting the value to an integer: the text `'4'` becomes `4`. `'4x'` and the empty string are format errors, and `'11'` is valid in format but outside the range; all three return `false`. This is why the return type is `int|false`, and the caller has to check for `false` explicitly; if `false` is used as a number, it behaves like zero. The count shows the set the validator defines: of 13 values, only the ten between 1 and 10 pass.

## Checking the Return Value

The `int|false` return has a trap: a valid result can be `0`, and `0` counts as false in PHP. The measurement below checks a validator whose lower bound is zero in two ways: with a loose comparison (`$result == false`) and with a strict comparison (`$result === false`).

```php
<?php
// zero.php — when zero is a valid value
declare(strict_types=1);

function validateStock(string $input): int|false {
    return filter_var($input, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 10]]);
}

foreach (['0', '5', '4x'] as $input) {
    $result = validateStock($input);
    echo var_export($input, true), ' → ', var_export($result, true),
        ' · loose reject ', var_export($result == false, true),
        ' · strict reject ', var_export($result === false, true), "\n";
}
```

```
'0' → 0 · loose reject true · strict reject false
'5' → 5 · loose reject false · strict reject false
'4x' → false · loose reject true · strict reject true
```

The loose comparison rejects the valid input `'0'` as well: `0 == false` is true. The strict comparison catches only the real `false`, that is, the format error `'4x'`. The validator's contract is therefore read with `=== false`; if zero is in the valid set, a loose check silently loses a valid value. This time the fault is on the function side, not the security side, but it comes from the same root: if the result of validation is read wrongly, validation itself proves nothing.

## Party: Validation Does Not Change the Owner

The course's question now goes to this result. The core file is `party.php`, given in the same form in every lesson; `EXTERNAL_PARTIES` holds the three parties outside the code in decision order, `probe()` computes the result first without deviation and then separately with each deviation, and `party()` picks the first party that changed it, or the server if none could. The order is the width of the attack surface: the client is everyone who sends a request, the third party is only the parties the application connects to, and the environment is only in the hands of whoever deploys.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

The measured result is the validated quantity. In the undeviated request the client sends `'3'`; the client's deviation is another valid number, `'7'`.

```php
<?php
// quantity.php — the party of the validated quantity
declare(strict_types=1);
require 'party.php';
require 'input.php';

partyReport('quantity (validated)', fn(array $d) => validateQuantity($d['client'] ?? '3'), ['client' => '7']);
```

```
quantity (validated) → client
    client changed
```

The result belongs to the **client**. Validation did not reject `'7'`, because `'7'` is inside the valid set; the result moved from `3` to `7`. Validation does not prevent the client from changing the value. What it does is narrow the values the client can change to the ten integers in the count: `'4x'`, `''` and `'11'` turn the result into `false` and never reach the rest of the application. The narrowing is a gain, but the owner has not changed; the quantity is still set by whoever sends the request.

## The Bounding Measurement

Next to validation there is a second step that is often confused with it. **Sanitization** does not reject the input, it changes it: `FILTER_SANITIZE_NUMBER_INT` deletes every character except digits, plus and minus, and accepts what remains. The two steps are separate: validation asks "is this value valid?" and can say no; sanitization cannot say no. The same three inputs pass through both, and the sanitized value is also given to the validator.

```php
<?php
// sanitize.php — sanitization and validation side by side
declare(strict_types=1);
require 'input.php';

foreach (['4', '4x', '1x0'] as $input) {
    $sanitized = filter_var($input, FILTER_SANITIZE_NUMBER_INT);
    echo var_export($input, true), ' → sanitization ', var_export($sanitized, true),
        ' · validation ', var_export(validateQuantity($input), true),
        ' · sanitize then validate ', var_export(validateQuantity($sanitized), true), "\n";
}
```

```
'4' → sanitization '4' · validation 4 · sanitize then validate 4
'4x' → sanitization '4' · validation false · sanitize then validate 4
'1x0' → sanitization '10' · validation false · sanitize then validate 10
```

The result points in the expected direction, and it is unsettling. Validation rejects `'4x'` and `'1x0'`; sanitization accepts both, and by changing them: `'4x'` silently becomes `4`, and `'1x0'` silently becomes `10`. The sanitized value passes the validator, because sanitization has erased the malformation. The information that the input was malformed is lost at this point: the application receives an order of four items, while the user typed `4x`. The bounding result is this: sanitization does not take the place of validation. A malformed input is rejected first by validation; sanitization is used where the value cannot be rejected and only has to be brought into a safe form.

## Summary

- The measured class of this lesson is the **client**: the client changed the validated quantity from `'3'` to `'7'`; validation did not prevent it.
- `filter_var()` with `FILTER_VALIDATE_INT` and range options narrows the input to the valid set: of 13 values only ten passed, and an invalid format returns `false`.
- Validation rejects, sanitization changes and accepts; the two are separate steps and neither replaces the other.
- Sanitization silently turns a malformed input into another value; an input that should have been rejected enters the result as a value.
- A defense does not always change the owner: validation does not move the result to the server, it only narrows what the client can change.

## Next Step

A validated value, as long as it stays in the valid set, is still a value chosen by the client. So if this value goes into a query text, who determines the text itself? The Preventing SQL Injection lesson measures that a prepared statement takes the query text from the client and gives it to the server.
