Public forms are where most sites meet hostile input for the first time. The controls below are not exotic, but they are frequently missing, and each addresses something we see exploited in real incidents.
Validate on the server, always
Anything enforced only in the browser can be removed by anyone who opens developer tools. Client-side checks exist to give fast feedback; they are not a security boundary. Every constraint that matters — types, lengths, ranges, allowed values — must be enforced again on the server, ideally through a schema that rejects anything unexpected rather than sanitising it.
Treat uploads as hostile
- Check the actual file content rather than trusting the extension or declared type.
- Enforce a size limit at the request boundary, before the file is read into memory.
- Generate your own filenames; never use the user-supplied one on disk.
- Store files outside the web root, ideally in object storage, and serve them from a separate origin with headers that force download rather than execution.
Rate-limit anything with a cost
Login, password reset, search, upload, contact forms, and any endpoint that sends email or calls a paid API. Without limits, these are both an availability problem and a billing problem. Limit by IP and by account where you can, and return a clear retry message rather than failing silently.
Do not leak which accounts exist
Password reset and registration endpoints commonly reveal whether an address is registered through different messages or response times. Return the same response either way, and send the differentiating information by email. The same applies to login errors: one generic message for both wrong password and unknown account.
Bot pressure without punishing users
A honeypot field hidden from humans, a minimum time-to-submit, and same-origin checks remove a large share of automated submissions at zero cost to real users. Escalate to a challenge only for traffic that already looks abusive — challenges applied to everyone cost you genuine submissions, which on a reporting or intake form means losing the people you exist to help.
Handle errors without narrating your stack
Detailed errors belong in your logs, not in responses. A stack trace or database message returned to the browser hands an attacker a map. Return a short generic message with a reference identifier, and keep the detail server-side where you can correlate it.
Confirm what happened
A submitted form should tell the user unambiguously that it worked, and what happens next. This is a security property as well as a usability one: users who are unsure whether a submission succeeded resubmit repeatedly, and on a case-intake form that produces duplicate records that are genuinely expensive to reconcile.