Input Sanity Checks in PHP and JavaScript
Validate on the client for speed, validate on the server for safety — and understand exactly why skipping the second one gets you hacked.
There is one sentence that explains this entire lesson:
Client-side validation is a convenience. Server-side validation is the only validation.
Everything below follows from that.
#Why the client cannot be trusted
Your beautiful JavaScript form checks are running on a computer you do not control. Anyone can:
- Open DevTools and delete the
requiredattribute. - Disable JavaScript entirely.
- Send the request directly with
curl, never loading your page at all.
curl -X POST https://example.com/register \
-d "email=notanemail&age=-99&role=admin"Your form never ran. Your validation never ran. Whatever your server does next is all that stands between that request and your database.
So why bother with client-side checks at all? Because the alternative is a full round trip to be told you mistyped your email. Client-side validation exists to make the form pleasant. Server-side validation exists to make it safe. You need both, and they are not interchangeable.
#Client-side, layer one: HTML
Before writing any JavaScript, the browser already does a lot:
<form method="post" action="" novalidate id="signup">
<label for="email">Email</label>
<input type="email" id="email" name="email" required
maxlength="120" autocomplete="email">
<label for="age">Age</label>
<input type="number" id="age" name="age" required min="13" max="120">
<label for="username">Username</label>
<input type="text" id="username" name="username" required
pattern="[A-Za-z][A-Za-z0-9_]{2,15}"
title="Start with a letter, 3–16 characters, letters/numbers/underscore">
<button type="submit">Create account</button>
</form>type="email", required, min, max and pattern are all enforced by the browser with no code from you. They also give mobile users the right keyboard, which is a real usability win.
The novalidate attribute above turns off the browser's own error bubbles so we can show nicer messages ourselves — while the constraints themselves stay available to JavaScript through the Constraint Validation API.
#Client-side, layer two: JavaScript
const form = document.querySelector('#signup');
const rules = {
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v) || 'Enter a valid email address.',
age: (v) => {
const n = Number(v);
if (!Number.isInteger(n)) return 'Age must be a whole number.';
if (n < 13) return 'You must be at least 13.';
if (n > 120) return 'Please enter a realistic age.';
return true;
},
username: (v) =>
/^[A-Za-z][A-Za-z0-9_]{2,15}$/.test(v) ||
'Start with a letter, 3–16 characters, letters, numbers or underscore.',
};
function validateField(field) {
const rule = rules[field.name];
if (!rule) return true;
const result = rule(field.value.trim());
const errorEl = field.parentElement.querySelector('.error');
if (result === true) {
field.setAttribute('aria-invalid', 'false');
if (errorEl) errorEl.textContent = '';
return true;
}
field.setAttribute('aria-invalid', 'true');
if (errorEl) errorEl.textContent = result;
return false;
}
// Validate on blur, then live once a field has been touched
form.addEventListener('blur', (e) => {
if (e.target.name) {
validateField(e.target);
e.target.dataset.touched = 'true';
}
}, true);
form.addEventListener('input', (e) => {
if (e.target.dataset.touched) validateField(e.target);
});
form.addEventListener('submit', (e) => {
const fields = [...form.elements].filter((el) => el.name);
const allValid = fields.map(validateField).every(Boolean);
if (!allValid) {
e.preventDefault();
form.querySelector('[aria-invalid="true"]')?.focus();
}
});Two details that separate a good form from an annoying one:
Do not validate while someone is still typing their first character. Flagging d as an invalid email address while the user is on their way to dom@example.com is hostile. Validate on blur, and only then switch that field to live feedback — that is what the touched flag does.
Use fields.map(validateField).every(Boolean), not .every(validateField). every short-circuits on the first failure, so only the first bad field would get its error message. Mapping first guarantees every field is checked and every message appears.
Setting aria-invalid matters too: screen readers announce it, and it gives your CSS a hook (input[aria-invalid="true"] { border-color: crimson; }) that stays in sync with reality.
#Server-side: the validation that counts
<?php
declare(strict_types=1);
function e(string $v): string {
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
$errors = [];
$old = ['email' => '', 'age' => '', 'username' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$age = trim($_POST['age'] ?? '');
$username = trim($_POST['username'] ?? '');
$old = compact('email', 'age', 'username');
// --- Email ---
if ($email === '') {
$errors['email'] = 'Email is required.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'That does not look like a valid email address.';
} elseif (strlen($email) > 120) {
$errors['email'] = 'Email is too long.';
}
// --- Age ---
$ageInt = filter_var($age, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 13, 'max_range' => 120],
]);
if ($age === '') {
$errors['age'] = 'Age is required.';
} elseif ($ageInt === false) {
$errors['age'] = 'Enter a whole number between 13 and 120.';
}
// --- Username ---
if ($username === '') {
$errors['username'] = 'Username is required.';
} elseif (!preg_match('/^[A-Za-z][A-Za-z0-9_]{2,15}$/', $username)) {
$errors['username'] = 'Start with a letter, 3–16 characters.';
}
if (!$errors) {
// Safe to use. Redirect so refresh cannot resubmit.
header('Location: welcome.php');
exit;
}
}
?>#filter_var is doing real work here
FILTER_VALIDATE_EMAIL implements a battle-tested parser so you do not have to invent an email regex. FILTER_VALIDATE_INT with min_range and max_range validates the type and the bounds in one call, and returns the converted integer on success.
#Validate, then sanitise — and know the difference
Validation asks "is this acceptable?" and rejects it if not. Sanitisation changes the data to make it safe for a specific destination.
Validation first, always. Silently stripping characters from a password or trimming a name to fit is worse than telling the user what is wrong.
And critically: sanitisation is context-specific. There is no such thing as generically "clean" data.
| Destination | The correct tool |
|---|---|
| HTML page | htmlspecialchars($v, ENT_QUOTES, 'UTF-8') |
| SQL query | Prepared statements with bound parameters |
| Shell command | escapeshellarg() — or better, do not shell out |
| URL parameter | urlencode() |
| JSON output | json_encode() |
| Email header | Reject anything containing \r or \n |
A value that is perfectly safe in HTML can still break a SQL query. Escape for where it is going, at the moment it goes there.
#The two attacks this prevents
#SQL injection
Never build a query by concatenation:
// CATASTROPHIC — never do this
$sql = "SELECT * FROM users WHERE email = '$email'";Input of ' OR '1'='1 turns that into a query returning every user in the table. Input of '; DROP TABLE users; -- does what it says.
Prepared statements fix this completely, because the query structure is sent to the database before the values, so a value can never be reinterpreted as SQL:
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();ATTR_EMULATE_PREPARES => false is important — it makes PDO use the database's real prepared statements rather than doing string substitution in PHP.
#Cross-site scripting
Covered in the forms lesson, and it bears repeating because it is the one people get wrong twice: escape on output, every time, with htmlspecialchars(). Store what the user typed; escape it when you print it.
#Passwords, in three lines
Since this is where credentials usually show up:
// Storing
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// Checking
if (password_verify($submittedPassword, $hash)) { /* correct */ }password_hash picks a strong algorithm, generates a unique salt automatically, and embeds everything in the output string. password_verify handles the comparison in constant time.
#The checklist
Every user input, every time:
- Does it exist? (
??with a default) - Is it the right type? (
filter_var,ctype_*) - Is it within sensible bounds? (length, range, allowlist)
- Does it match the expected format? (regex, anchored at both ends)
- Escape it for its destination at the moment you use it.
Check yourself
5 questions on what you just read. No score is kept — it is only here to catch the bits that did not stick.
-
1Why is client-side validation never enough?
Show the answer
A. The request can be sent directly with curl, never loading your page.
Your JavaScript runs on a machine you do not control. Client-side checks are UX; server-side checks are security.
-
2
filter_varreturnsfalseon failure. Why isif (!$value)a bug?Show the answer
A. A legitimate result of
0is falsy and would be reported as invalid.Compare with
=== false. The same trap applies tostrpos(), which returns0for a match at position zero. -
3What actually stops SQL injection?
Show the answer
A. Prepared statements, because the query structure is sent before the values.
Once the database has parsed the query, a bound value can never be reinterpreted as SQL. Placeholders bind values only — never table or column names.
-
4How should passwords be stored?
Show the answer
A.
password_hash(), and verified withpassword_verify().MD5 and SHA-1 are fast, which is exactly wrong here — a GPU tries billions of guesses a second.
password_hashis deliberately slow and salts for you. -
5You have a clean, validated string. Is it safe to drop into any context?
Show the answer
A. No — escaping is per-destination: HTML, SQL, shell and URL each need their own.
There is no such thing as generically clean data. Escape for where the value is going, at the moment it goes there.
#Key takeaways
- Client-side validation is UX. Server-side validation is security. Never trade one for the other.
- Validate on blur, not on the first keystroke, and never short-circuit before every field is checked.
filter_varhandles email and bounded integers properly — check=== false, since0is falsy.- Sanitisation is per-destination. HTML, SQL, shell and URL each need their own escaping.
- Prepared statements end SQL injection. Placeholders bind values only, never identifiers.
password_hashandpassword_verify. Nothing else, ever.
Next: making it all look like something you would show people.
Finished this one?
The challenge above has no posted solution — that is deliberate. Get it working, then come back for the next lesson.