Contact Form Spam Protection: What Our API Checks
Oct 20269 min read
Contact form spam protection, shown through the ten checks our own two form endpoints run before any email is sent, and what each one stops.
- Form security
- Rate limiting
- Input validation
Contact form spam protection is a server-side job: limit how often one client can post, validate every field against a strict schema, trap bots with a hidden field, and escape every value before it reaches an email. This post walks through the checks the two form endpoints on mkdirhq.com run before any mail is sent.
Both endpoints are public. One takes general enquiries from the contact page, the other takes free audit and trial requests. Neither can ask for a login, because the whole point is that a stranger can write to us. Each one sends two emails per submission: a notification to the founders and an acknowledgement to the address the visitor typed.
That second email is what makes a contact form risky. An endpoint that mails text chosen by an anonymous caller, to an address chosen by the same caller, is one missing check away from being a spam relay. A comment at the top of both handlers says as much: without a cap, the endpoint is a free relay and a fast way to burn the sending quota.
What does a contact form endpoint need to check before it sends email?
Ours run in a fixed order, cheapest first. The rate limit comes before the request body is read. Then the body is parsed as JSON, validated against a schema, and tested against the honeypot. Only after that does the route build an email, and every submitted value is escaped on the way in.
| The check | What it stops | What it costs to add |
|---|---|---|
| Rate limit per client IP, first thing in the handler | One script hammering the endpoint, and the mail quota it would burn | One small function and an in-memory map, no new dependency |
| Client IP read from the proxy's own header, with a constant fallback | Callers who forge a forwarded-for value to get a fresh limit on every request | One function that reads the trusted header first |
| Schema validation: trimmed, length-capped, fixed choices | Oversized payloads, blank required fields, values the form never offered | One schema per route, written with a schema validation library |
| One email rule and one list of choices shared by form and route | A visitor who passes the form and is then rejected by the server | A shared module that both sides import |
| Server-side copy of the form's conditional rules | Requests that skip the browser and post straight to the endpoint | A short refinement on the schema |
| Honeypot field that answers with a fake success | Bots that fill in every input they find | One hidden input, one optional schema field, one branch |
| HTML escaping of every submitted value | Markup or links injected into the emails we send | One function that replaces five characters |
| First-name guard on the greeting | A URL or domain mailed to a third party under our name | One short function |
| Generic error messages, details kept in the server log | Mail setup details leaking to the caller | A fixed string in the catch block |
| Best-effort acknowledgement email | A bounced acknowledgement failing a submission we already hold | A try and catch around the second send |
How do you rate limit a contact form without a database?
We keep a map in memory. The key is the endpoint's name plus the client IP, and the value is a list of timestamps. Each request drops the timestamps that have aged out of a fixed window, then counts what is left. Past a small limit, the next request inside the window gets a 429 status and a Retry-After header, in seconds, counted from the oldest request still in the window.
Two details in that function are easy to get wrong. A rejected request adds no timestamp, so retrying against a closed window does not extend it. And because the endpoint's name is part of the key, the contact form and the audit form have separate budgets.
The map would grow without bound on a long-lived instance. Once it passes a set size, the function sweeps out every key whose timestamps have all expired.
Which IP address do you count?
The obvious choice is the first entry in the X-Forwarded-For header, and it is the wrong one. That entry is whatever the client sent. Our hosting platform's edge proxy appends to the list it receives and leaves the earlier entries alone. A script can send a different made-up address on every request and never reach the limit.
So the key comes from the header the edge proxy sets itself, which a client cannot write. If no trusted address is available at all, the key falls back to a constant. That sounds harsh, since every such caller then shares one bucket. The alternative, skipping the limit when no address is found, would switch it off silently.
What an in-memory limiter is for
An in-memory limiter is a first line, and we treat it as one. Its memory belongs to a single running copy of the server code, so its counts are neither shared nor permanent. The comment at the top of our rate limit file says this in plain words.
It stops the common case, one script hammering one endpoint, with no dependency and no infrastructure. The same comment names what sits behind it when that is not enough: a rate limiting rule at the platform's edge, or a durable counter behind the same function signature. Because the routes only see a function that returns allowed or not, either one slots in without touching them.
Why validate on the server when the form already validates?
Nobody has to use the form. The endpoint accepts a POST from anything that can send one, and browser checks never run for a script. A comment in our audit route puts it this way: client-side required is a convenience, not a control.
Every text field is trimmed, then checked against a maximum length, and required fields against a minimum too. Each cap sits a little above anything the form lets a person type, so a real visitor never meets one. The audit form has longer free-text fields with larger caps. Its multi-select answers are capped twice: on the number of items and on the length of each one.
Trimming first matters. A name made only of spaces has length, and without the trim it would pass a minimum-length check and arrive as a blank row in our inbox.
Choice fields are checked against a fixed list. The 'interested in' options on the contact form live in one module. The form renders its choices from that list and the route accepts only values in it, so the two cannot drift apart.
The email rule lives in the same module, for a reason we learned on our own form. The browser used to accept anything shaped like x@y.z while the server ran a stricter check. A visitor could pass the form and then be bounced by the API with no explanation. Now both sides call one rule: trimmed, at most 254 characters, valid address format.
The audit form has two modes. A demo request needs four fields. A trial request also needs seven answers describing the ideal customer, because a trial cannot be configured without them. The form marks those fields required in trial mode, and the schema repeats the rule on the server, adding one error for each missing answer.
A body that is not valid JSON gets a 400. A body that fails the schema gets a 422, with the list of fields that failed and why.
How does a honeypot field stop bots without a CAPTCHA?
Both forms carry one extra input that is hidden from people and that bots tend to fill. If it arrives with any value, the route logs a warning, sends nothing, and answers with the same success response a real submission gets. A bot that is told it failed can adapt. One that is told it succeeded has no reason to.
The field's name took a second attempt. It was first called 'website', the classic choice, and that is also a real field name. Browser autofill and password managers fill anything with that name, and the audit form has a real website input of its own. A person could trip the trap without touching it. The field now has a name that matches no real form field.
The warning in the log exists for the same reason. If a real person is ever caught by autofill, the dropped submission shows up somewhere we can see it.
One more deliberate choice: the honeypot has no maximum length in the schema, and every other field does. A bot that stuffs a very long string into the hidden input should trip the trap and get its fake success. With a cap, it would get a validation error and learn something.
A honeypot does nothing against a person typing spam by hand. The rate limit is what bounds that case.
What stops a contact form from mailing someone else's message?
Format validation shows an address is well formed. It cannot show that the person typing it owns the mailbox. Anyone can enter a stranger's address, and our acknowledgement will go there. A form with no login cannot close that gap, so the goal is to make the email worthless to an abuser.
The acknowledgement is fixed copy written by us. The only submitted value in it is the first word of the name field, used in the greeting. That word is checked before it is used. It has to look like a name: short, and free of the characters that make up a link or an address. Anything else gets a neutral greeting.
Without that guard, someone could put a link in the name field and a stranger's address in the email field. We would then deliver that link, in a branded email, from our own domain. With the guard and the rate limit together, what is left is a handful of acknowledgements per window, each carrying at most one word the sender chose.
Escaping every value before it reaches an email
The notification to the founders is HTML and contains everything the visitor typed. Each value passes through one escape function first. It replaces five characters: ampersand, less-than, greater-than, double quote and single quote. The helpers that build the email's rows and blocks expect strings that are already escaped, and their comments say so, which keeps the rule visible wherever they are called.
Empty optional fields become a hyphen before escaping, so the layout never depends on what was left blank. The subject line is plain text and carries the name and company as typed, inside their length caps. Recipients, reply-to and subject are handed to the mail library as separate structured fields. The route never assembles raw headers from input.
What should the endpoint say when something breaks?
As little as possible. If the mail provider rejects the send, the full error goes to the server log and the caller gets one fixed sentence with a 500 status. The same happens if the mail credentials are missing from the environment. A provider's error text can describe our mail setup, and that does not belong in a public response.
The acknowledgement gets the opposite treatment. It is sent after the founders' notification, inside its own try and catch. If it bounces, the failure is logged and the request still returns success, because the enquiry is already with us. Failing the request at that point would tell a visitor their message was lost when it was not.
Why does it take ten checks and not one?
No single check here is the defence. The rate limit bounds volume, validation bounds content, the honeypot filters automation, and escaping protects whoever reads the email. Each one covers a gap the others leave, which is why none of them is optional.
None of the ten checks needed a new service. Most are a few lines beside code that had to exist anyway. We build client systems the same way, whether that is an AI agent drafting replies for a person to approve or an internal tool. Input from outside is validated at the boundary and escaped at the point of output.
Sources
- OWASP Cheat Sheet Series, Input Validation Cheat Sheet: server-side validation, allowlists and length limits (https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html)
- OWASP Cheat Sheet Series, Cross Site Scripting Prevention Cheat Sheet: the five characters to encode in an HTML context (https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
- OWASP API Security Top 10, API4:2023 Unrestricted Resource Consumption: rate limiting and operations that send email (https://api-security.owasp.org/editions/2023/en/0xa4-unrestricted-resource-consumption)
- OWASP Cheat Sheet Series, Error Handling Cheat Sheet: log details on the server, return a generic response (https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)
- MDN Web Docs, X-Forwarded-For header: only addresses added by a trusted proxy are safe for rate limiting (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For)
- MDN Web Docs, Retry-After header: use with a 429 Too Many Requests response (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After)