All notes
08Note5 min

A contact form that tells the truth

  • Forms
  • Security
  • UX

Silent failure for bots, honest failure for humans, and why the rate limiter and the honeypot deliberately behave differently.

The enquiry form has two defences that look similar and are designed to behave in opposite ways. Getting that difference right matters more than either mechanism.

The honeypot lies

A caught bot is told the submission succeeded. This is not politeness — an honest error is a free oracle. Tell an automated submitter exactly which check tripped and it tunes and retries; tell it that everything worked and it has no signal to learn from.

ts
const botReason = detectBot(data, now)
if (botReason) {
  console.warn(`[enquiry] discarded — ${botReason}`)
  // Success shape, discarded payload. Deliberate.
  return { status: 'success', message: "Thanks — we've got it." }
}

The cost is real and needs stating: a human caught by the honeypot is told their message sent when it did not. A password manager filling the decoy field is the plausible case. That is why the decoy has a deliberately odd name and autoComplete off, and why the timing threshold is a lenient three seconds rather than something aggressive.

The rate limiter does not

Someone sending three enquiries in ten minutes is far more likely to be a customer double-submitting than an attacker. They get an honest message, a real wait time, and a direct email address as a way out.

Silently swallowing a real customer's fourth message is a worse outcome than giving an attacker a small amount of information about your rate limit.

The two mechanisms sit ten lines apart and behave in opposite ways, because they are defending against different people. Treating them the same — either both silent or both honest — gets one of the two cases wrong.

Never lose what they typed

Every error path echoes the submitted values back into the form. Validation failure, rate limit, delivery failure — all of them. A form that clears itself on a server error is asking someone to retype four hundred words because your mail provider had a bad minute.

And never leak why it broke

ts
catch (cause) {
  // Real error to the log, generic message to the browser.
  // Transport errors carry API keys, hostnames and stack traces.
  console.error('[enquiry] delivery failed:', cause)
  return {
    status: 'error',
    message: 'Something went wrong sending that. Please email us directly.',
    values,   // ...but keep their words
  }
}

Validation runs server-side even though the inputs carry required and type=email. Client attributes are a convenience for the user, not a guarantee for you — the action is a public endpoint reachable without ever rendering the form.

We build this way for clients too.

Start a project