Cybersecurity Featured

A Ten-Line Security Checklist for Every Form You Build

Client-side validation is a usability feature, not a security control. Anyone can send your endpoint whatever they like with a two-line script. This checklist takes ten minutes and closes the gap that accounts for most real-world breaches.

Server side, every single time

  • Validate types and lengths. An email field that accepts a 400 KB string is a denial-of-service waiting to happen.
  • Never trust a hidden field. Prices, roles, user ids and “is_admin” flags must be re-derived on the server from the session.
  • Use prepared statements. String-concatenated SQL is still the most common injectable mistake, and placeholders cost nothing.
  • Escape on output, not on input. Store what the user gave you, escape when you render it. Escaping on input corrupts data and hides the real problem.
  • Protect state-changing requests. A CSRF token plus an Origin/Referer check. Without them, any page on the internet can post to your form using the visitor's session.

Validating input is not escaping output

These two jobs fail in opposite directions, and merging them is how a validation bug becomes an XSS bug. Validation runs on the way in and answers one question: do I accept this value at all? It is an allow-list — a length, a format, a set of permitted values. Escaping runs on the way out and depends entirely on the destination, because the same string needs different treatment in HTML, in an attribute, in JSON and in a SQL parameter.

Advertisement
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) { http_response_code(400); exit; }

$stmt = $pdo->prepare('SELECT title FROM articles WHERE id = ?');
$stmt->execute(array($id));

The filter is convenience; the placeholder is the boundary. A comment field containing an apostrophe and a <3 is valid input that must still be escaped when you render it.

The four lines people forget

  • Rate limit the endpoint per IP and per account. It turns credential stuffing from “a million attempts” into “a hundred, then blocked”.
  • Honeypot a hidden field. Real visitors never fill it; most bots do. It is one line of markup and no CAPTCHA.
  • Cap the body size before parsing. post_max_size and an upload size check are cheap insurance.
  • Log the event, not the password. Successful and failed submissions, with an IP and a timestamp, are what let you reconstruct an incident later.

Password resets are a login bypass in disguise

A reset flow is the softest door in most applications: an unauthenticated request that changes a credential. Generate the token from random_bytes(), persist only its hash, expire it within the hour, and invalidate every outstanding token when the password changes.

$token  = bin2hex(random_bytes(32));
$stored = hash('sha256', $token);   // only the hash is stored

Compare with hash_equals() rather than ==, then answer reset, registration and login with the same sentence whether or not the address exists. Different responses turn the form into an account enumeration oracle, and an enumeration oracle is the first step of every targeted attack.

Credentials, briefly

// Store: never reversible, always salted by the algorithm
$hash = password_hash($password, PASSWORD_DEFAULT);

// Verify: constant time, no manual comparison
if (!password_verify($input, $storedHash)) { /* reject */ }

Then force a re-hash when the algorithm improves, and give every session a regeneration on privilege change. Passwords should never appear in logs, error messages or a database column that is readable by the application user.

Test it like an attacker

Before you ship, submit the form with: an empty field, a five-megabyte field, a quote character, a <script> tag, a negative number, and someone else's record id. Six attempts. If any of them produce a stack trace, a stored script tag or another user's data, you have found your next bug before a stranger did.

File uploads deserve their own checklist

An upload field combines attacker-controlled bytes, a filesystem path and a public URL, which is why one careless handler produces so many serious bugs.

  • Never trust Content-Type. It is a client-supplied header. Allow-list the extension and confirm the real type with finfo_file().
  • Rename every file. Store a generated name with a known-safe extension; a stored payload.php inside a served directory is remote code execution.
  • Keep uploads outside the web root and serve them through a script that sends Content-Disposition: attachment and a fixed content type. That also removes the stored-XSS route through SVG, which browsers execute as markup.
  • Basename the supplied name. ../, encoded variants and null bytes exist only to escape your upload directory.

If you only need an image, decoding and re-encoding it server-side is the strongest control available — nothing hidden in the metadata survives the round trip.

Rate limit on two keys, then watch the numbers

Limit anything that verifies a secret or spends money: login, reset, OTP entry, contact forms, search. Count per IP to stop one machine and per account to stop a distributed attack on one victim; either key on its own is bypassed in an afternoon.

$key  = 'login:' . strtolower($email) . ':' . floor(time() / 900);
$hits = $redis->incr($key);
if ($hits === 1) { $redis->expire($key, 900); }
if ($hits > 5)   { http_response_code(429); exit; }

A fixed window permits a burst across the boundary; a sliding window or a token bucket smooths it out for a little more state. Log the counter, because a limiter nobody watches breaks silently.

Headers, logs, and what still gets through

Three response headers close the cheapest attacks: Strict-Transport-Security prevents a silent TLS downgrade, X-Content-Type-Options: nosniff stops a browser re-reading an upload as script, and a Content-Security-Policy without unsafe-inline turns an injected <script> into a blocked request. Ship CSP in report-only mode first; the OWASP Secure Headers Project documents the exact syntax.

Logs leak too. Decide up front what must never appear in them: passwords, reset tokens, session ids, Authorization headers, card numbers. Record the shape instead — event name, user id, a truncated hash of the identifier, IP, reason.

Finally, be honest about the ceiling. This list stops scanners, bots and copied exploits, which is the large majority of what a small site actually receives. It does not stop business-logic abuse that stays inside your own limits, a stolen session cookie, or a vulnerable dependency your correct code calls. Above all it says nothing about authorisation: a form that validates every field perfectly and then loads /invoice/1234 without checking who owns that invoice is broken no matter how good the validation is.

Advertisement
khallaf

Writing about programming, AI and the tools that make engineering teams faster. Published by A1 Systems.

Last updated 19 Sep 2026

// Keep reading

Related articles

Tools & Tricks 5 min read

Regex you will actually use

The small set of regex constructs that cover everyday work, the patterns worth keeping in a snippet file, and how to avoid catastrophic backtracking.

khallaf Tip