← back to blog
5 min read
  • #web-security
  • #building-in-public
  • #forms

Building a contact form that doesn't get spammed

Three layers of defense, each cheap, each measurable.


A contact form is the one place on a small site where you actively invite strangers to send you data and trigger an email. That is exactly why bots love it. Leave it open and you get a steady drip of casino links and SEO spam in your inbox, and if the form sends email on submit, someone can point a script at it and burn through your sending quota in an afternoon. I built the form on my own site to survive that, and the approach I settled on is three layers, each one cheap to add and each one easy to measure. None of them is clever. That is the point.

I am still studying for my eJPT and Security+, so treat this as study notes from someone who built the thing and watched the logs, not as gospel from a certified pro. But this pattern held up, and I can show you why each layer earns its place.

Layer 1: a honeypot field

Most form spam is automated. A bot loads your page, finds every input, fills them all in, and submits. So you give it an input that a real person will never see or touch. If that field comes back filled, you are almost certainly looking at a bot, and you drop the submission silently.

Add a field that is hidden with CSS, not with the hidden attribute (some bots skip obviously hidden fields, and screen readers should ignore it too):

<div style="position:absolute;left:-9999px" aria-hidden="true">
  <label>Leave this blank
    <input type="text" name="company_website" tabindex="-1" autocomplete="off" />
  </label>
</div>

On the server, the check is one line:

// Honeypot: real users never fill this. Bots fill everything.
if (body.company_website && body.company_website.trim() !== "") {
  // Pretend it worked. Don't tell the bot it failed.
  return NextResponse.json({ ok: true });
}

Two details matter. Name it something a bot wants to fill, like company_website or url, not honeypot. And return a normal success response, not an error. If you return a 400, the bot's author learns the trap exists and codes around it. Silence teaches it nothing.

Why it is cheap: a few lines and one hidden div. Why it is measurable: log every silent drop. On my form, this one field catches the large majority of junk on its own. Watch that number for a week and you will know exactly how much work it is doing.

Layer 2: rate limiting, per IP and per email

The honeypot stops dumb bots. It does not stop someone who actually looks at your form and decides to abuse it. For that you cap how often the endpoint can be hit. I do it on two keys, and the second one matters more than people expect.

Per IP stops one machine from looping the endpoint. Per email stops an attacker from email-bombing one specific victim, even if they rotate IPs. This is the exact shape I use on my lead-magnet route:

// Per IP: stop one machine from flooding the endpoint.
const ip = getClientIp(req);
const ipLimit = rateLimit(`contact:ip:${ip}`, {
  limit: 5,
  windowMs: 60 * 60 * 1000, // 5 per hour
});
if (!ipLimit.ok) {
  return NextResponse.json(
    { error: "Too many requests. Please try again later." },
    { status: 429, headers: { "Retry-After": String(ipLimit.retryAfterSec) } },
  );
}

// Per email: even across rotating IPs, nobody can bomb one address.
const emailLimit = rateLimit(`contact:email:${email}`, {
  limit: 3,
  windowMs: 24 * 60 * 60 * 1000, // 3 per day
});

A 429 with a Retry-After header is the correct, honest response here. You are telling the client exactly what happened and when to come back.

One caveat worth naming: a simple in-memory rate limiter resets when your serverless function cold-starts and does not share state across instances. It still raises the cost of abuse a lot, which is most of the value. If you outgrow it, move the counter to something shared like Redis or an Upstash store, keyed the same way.

Why it is cheap: the per-IP and per-email keys are a few lines each. Why it is measurable: count your 429s. A flat line of zero means nobody is pushing. A spike tells you someone tried and got stopped.

Layer 3: server-side validation and a length cap

Client-side validation is for humans who typo their email. It does nothing for an attacker, who never runs your JavaScript and posts straight to the endpoint. So every rule has to live on the server too.

Validate the shape, normalize, and cap the length of every field before you do anything with it:

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const email = String(body.email ?? "").trim().toLowerCase();
const name = String(body.name ?? "").trim().slice(0, 80);
const message = String(body.message ?? "").trim().slice(0, 2000);

if (!email || !EMAIL_RE.test(email)) {
  return NextResponse.json({ error: "Please enter a valid email." }, { status: 400 });
}
if (message.length < 1) {
  return NextResponse.json({ error: "Message is required." }, { status: 400 });
}

That String(...) wrapper matters: it means a malicious payload that sends email as an array or object cannot crash your handler. The .slice() caps are your size guard. Without them, someone can POST a multi-megabyte message field and you will happily store and email it. A 2,000-character cap on a contact message is generous for a human and brutal for a spammer pasting a wall of links. If you render that message anywhere later (an admin page, an email), escape it on output so a <script> in the body stays inert text.

Why it is cheap: it is the validation you should write anyway. Why it is measurable: log rejected submissions by reason. Watching which rule fires most tells you what is actually hitting you.

The honest wrap

Three layers, stacked: a honeypot to drop the dumb bots for free, rate limiting to make flooding expensive, and strict server-side validation so nothing past the gate is trusted. I run this on my own contact form and it has been quiet. No single layer is complete, which is the whole idea. Each one is cheap, each one logs something you can watch, and together they cover the gaps the others leave.

This same layered, log-what-you-block thinking is what I dig into in my ebook, Web Security for Builders ($29, aldowebsitellc.xyz/shop/web-security-for-builders), if you want the longer version with more of the patterns I have been working through.

$ share

community rating

$ ls ./comments

sign in or create an account to rate and comment.

no comments yet, be first.