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.

PHP & the Web Beginner 15 min read Watch the video (5:44)

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 required attribute.
  • Disable JavaScript entirely.
  • Send the request directly with curl, never loading your page at all.
bash
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:

html
<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

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
<?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.

DestinationThe correct tool
HTML pagehtmlspecialchars($v, ENT_QUOTES, 'UTF-8')
SQL queryPrepared statements with bound parameters
Shell commandescapeshellarg() — or better, do not shell out
URL parameterurlencode()
JSON outputjson_encode()
Email headerReject 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:

php
// 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:

php
$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:

php
// 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:

  1. Does it exist? (?? with a default)
  2. Is it the right type? (filter_var, ctype_*)
  3. Is it within sensible bounds? (length, range, allowlist)
  4. Does it match the expected format? (regex, anchored at both ends)
  5. 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.

  1. 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. 2filter_var returns false on failure. Why is if (!$value) a bug?

    Show the answer

    A. A legitimate result of 0 is falsy and would be reported as invalid.

    Compare with === false. The same trap applies to strpos(), which returns 0 for a match at position zero.

  3. 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.

  4. 4How should passwords be stored?

    Show the answer

    A. password_hash(), and verified with password_verify().

    MD5 and SHA-1 are fast, which is exactly wrong here — a GPU tries billions of guesses a second. password_hash is deliberately slow and salts for you.

  5. 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_var handles email and bounded integers properly — check === false, since 0 is 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_hash and password_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.

Next lesson Subscribe