A Contact Form That Does Not Leak
The boring security work behind three input fields
A contact form is the smallest possible feature with a real attack surface. It takes untrusted input, puts it in an email, and sends that email somewhere I will read it. Three fields, one endpoint, and several ways to get it wrong that I only found by going back and looking properly.
01Validate on Both Sides, for Different Reasons
There is client-side validation and server-side validation and they are not the same job. The client-side check exists so a visitor finds out about a malformed email address while they are still looking at the field, rather than after a round trip. It is a user experience feature and it has no security value at all, because anyone can post directly to the endpoint and skip the form entirely. The server-side check is the one that matters, and it should assume the request did not come from my form. I use the same schema definition on both sides so the rules cannot drift, which is one of the genuine wins of sharing a language across the stack, but the important part is that the server parses rather than trusts. It rejects anything that does not match the shape and returns a validation error, and the handler below never sees a value it has not checked. Reusing the schema is convenience. Running it on the server is the requirement, and the fact that the code looks identical hides how differently the two are load-bearing.
02The Bug I Actually Had
My handler built an HTML email by interpolating the submitted name and message directly into a template string. That is injection, and it is the same bug as putting unescaped input into a page, just with an email client as the renderer instead of a browser. A message containing markup would be rendered as markup by whatever I read mail in. It is a lower-severity version of the web problem because the audience is me rather than every visitor, but the reasoning that made it feel acceptable is the reasoning that produces real vulnerabilities elsewhere, so it was worth fixing properly rather than rationalizing. The fix is to escape the five characters that matter in HTML before interpolation, which is a handful of lines and no dependency. The better fix is to also send a plain text alternative, since the content is three fields and gains nothing from markup. What made this worth writing about is how it happened. It was not a hard mistake or an unfamiliar API. It was a template literal in a file that felt too small to review.
03Rate Limiting and the Quiet Costs
An unprotected send endpoint is a free email relay pointed at my inbox, and the way you find out is a few hundred submissions overnight. Rate limiting is the fix, and the practical constraint is where you store the counter. In-memory works on a single long-running server and fails silently on serverless, where each instance gets its own memory and the limit becomes per-instance rather than per-visitor. That failure is invisible in testing, because in testing there is one instance. A shared store is the correct answer. A honeypot field is worth the ten minutes it costs: a hidden input that humans never fill and naive bots fill every time, rejected server-side. It stops a surprising share of automated traffic for almost nothing, and unlike a challenge widget it costs legitimate visitors nothing at all. The other quiet cost is the sender address. Sending from a provider's shared onboarding domain works in development and lands in spam in production, and configuring the sending domain properly is the difference between a contact form and a contact form that reaches you.
04Failing Without Saying Too Much
The last piece is what the endpoint says when something goes wrong. The instinct is to be helpful and return the underlying error, and that instinct leaks. A provider error can carry an account identifier, a rate limit detail, or a stack path that describes my deployment. None of that helps the person who filled in the form, and all of it helps someone probing the endpoint. So the response is a generic failure message and the real error goes to the server logs where I can read it. The other half is not lying in the opposite direction. If the send failed, the form must say so, because a success message on a failed send means someone thinks they contacted me and is waiting for a reply I will never send. That is worse than an error, because the error at least prompts them to try another channel. So: honest about whether it worked, quiet about why it did not.
Takeaways
None of this is difficult and all of it is easy to skip, because a contact form looks too small to deserve a security review. Parse input on the server, escape anything you interpolate, rate limit with a store that survives your deployment model, and return errors that are honest about the outcome without describing your infrastructure.