Honeypot Fields and Bot Detection

How do you stop contact forms, sign-ups and comment boxes from filling with spam without putting a puzzle in front of every human? The cheapest effective answer is layered: a honeypot field that humans never see but naive bots fill, a signed timestamp that catches submissions made faster than any person could type, and a challenge that appears only when those signals are borderline. This recipe implements all three on the server, hides the honeypot from assistive technology as well as from sight, keeps the canonical novalidate plus reportValidity() flow of the Constraint Validation API for real validation errors, and never punishes a human for a false positive.

When to Use Honeypots and Timing Checks

Use them on any public, unauthenticated form that can be submitted repeatedly: contact forms, newsletter sign-ups, comments, reviews, quote requests, account registration. They work best when:

  • Spam is mostly automated and generic — bots that crawl the web and fill every form they find.
  • You want no visible friction for the large majority of real users.
  • You can combine them with rate limiting, covered in rate-limiting async validation endpoints.

They do not stop targeted attacks: a bot written for your specific form will skip the honeypot and wait out the timer. For high-value targets, escalate to a proper challenge service, verified on the server. The layering of all these controls is described in validation security and abuse prevention.

Bot signals by cost and strength Cards describing four spam defences — honeypot field, signed timing token, rate limiting and interactive challenges — with their cost to users and the bots they stop. Honeypot field invisible to humans and assistive tech; stops bots that fill every input Signed timing token invisible; stops instant submissions and replayed forms Rate limiting invisible until abused; stops volume from one source Challenge on escalation visible to a few users; stops scripted targeted attacks
Stack the invisible, cheap signals first and reserve anything a human can notice for the small share of borderline submissions.

Minimal Working Honeypot and Timing Check

<form method="post" action="/contact" novalidate>
  <!-- Honeypot: off-screen, not focusable, hidden from assistive technology. -->
  <div class="hp" aria-hidden="true">
    <label for="website_url">Leave this field empty</label>
    <input id="website_url" name="website_url" type="text" tabindex="-1" autocomplete="off">
  </div>
  <!-- Signed render timestamp, produced by the server when the form is rendered. -->
  <input type="hidden" name="form_token" value="{{ formToken }}">

  <label for="email">Email address</label>
  <input id="email" name="email" type="email" autocomplete="email" required>
  <label for="message">Message</label>
  <textarea id="message" name="message" required minlength="10"></textarea>
  <button type="submit">Send</button>
</form>
/* Not display:none — some bots skip hidden inputs. Off-screen instead. */
.hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.FORM_TOKEN_SECRET!;
const MIN_FILL_MS = 2500;             // faster than any human reads and types
const MAX_AGE_MS = 2 * 60 * 60 * 1000; // tokens expire after two hours

export function issueFormToken(now = Date.now()): string {
  const payload = String(now);
  const sig = createHmac("sha256", SECRET).update(payload).digest("base64url");
  return `${payload}.${sig}`;
}

type BotVerdict = "human" | "bot" | "suspicious";

export function assessSubmission(fd: FormData, now = Date.now()): BotVerdict {
  if (String(fd.get("website_url") ?? "") !== "") return "bot";           // honeypot filled

  const [payload, sig] = String(fd.get("form_token") ?? "").split(".");
  if (!payload || !sig) return "suspicious";                              // missing token: old page? challenge
  const expected = createHmac("sha256", SECRET).update(payload).digest("base64url");
  if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return "bot";

  const age = now - Number(payload);
  if (age < MIN_FILL_MS) return "suspicious";                             // too fast — maybe autofill
  if (age > MAX_AGE_MS) return "suspicious";                              // stale page — ask to confirm
  return "human";
}

export async function POST(request: Request): Promise<Response> {
  const fd = await request.formData();
  switch (assessSubmission(fd)) {
    case "bot":
      return new Response(null, { status: 303, headers: { location: "/contact/sent" } }); // pretend success
    case "suspicious":
      return renderContactWithChallenge(fd);                                              // escalate, don't reject
    case "human": {
      const parsed = contactSchema.safeParse(Object.fromEntries(fd));
      if (!parsed.success) return renderContactWithErrors(fd, parsed.error);             // normal 422
      await deliver(parsed.data);
      return new Response(null, { status: 303, headers: { location: "/contact/sent" } });
    }
  }
}

The signed token matters: a plain hidden timestamp is trivially forged by a bot that sets it to an hour ago. Signing with a server secret means the only way to get a valid token is to load the form, which costs the bot a real request and a real wait. Bot verdicts pretend success so the bot learns nothing; suspicious verdicts escalate to a challenge rather than discarding what might be a real person’s message.

Three submissions, three outcomes A naive bot fills the honeypot and receives a fake success; a fast autofilled human gets a challenge; a normal human passes straight to validation. Naive bot Fast human Normal human Server POST with website_url filled 303 fake "sent" POST 1.2 s after render form + accessible challenge POST 40 s after render validate → 303 or 422 errors
Bots are fooled, borderline humans get a way through, and ordinary humans never see any of it.

Bot Detection Option Reference

Option Type Default Purpose
Honeypot name string website_url Plausible name bots will fill; avoid honeypot
Honeypot hiding CSS + ARIA off-screen, aria-hidden, tabindex=-1 Invisible to people and assistive tech
MIN_FILL_MS number 2500 Below this, escalate rather than accept
MAX_AGE_MS number 2 hours Old tokens escalate, not fail
Token signature HMAC-SHA256 on Prevents forged timestamps
Bot response 303 to success page on Gives bots no signal to adapt to
Suspicious response challenge on Humans always have a path forward

Verification Steps

import { describe, it, expect } from "vitest";
import { assessSubmission, issueFormToken } from "./bot";

const fd = (fields: Record<string, string>) => {
  const f = new FormData();
  Object.entries(fields).forEach(([k, v]) => f.set(k, v));
  return f;
};

describe("assessSubmission", () => {
  const t0 = 1_700_000_000_000;
  it("flags a filled honeypot", () => {
    expect(assessSubmission(fd({ website_url: "http://spam", form_token: issueFormToken(t0) }), t0 + 60_000)).toBe("bot");
  });
  it("escalates instant submissions", () => {
    expect(assessSubmission(fd({ form_token: issueFormToken(t0) }), t0 + 500)).toBe("suspicious");
  });
  it("rejects forged tokens", () => {
    expect(assessSubmission(fd({ form_token: `${t0 - 60_000}.forged` }), t0)).toBe("bot");
  });
  it("accepts a normal human pace", () => {
    expect(assessSubmission(fd({ form_token: issueFormToken(t0) }), t0 + 45_000)).toBe("human");
  });
});

Edge Cases and Failure Modes

Autofill fills the honeypot. Browsers and password managers sometimes fill any text input with a plausible name. autocomplete="off" helps; a name that matches no autofill category helps more. website_url is a reasonable choice; email2 or name are not.

Screen readers reach the honeypot. display:none hides it from everyone but some bots skip hidden fields; off-screen positioning alone leaves it announced. The combination of off-screen positioning, aria-hidden="true" on the wrapper and tabindex="-1" on the input keeps it away from humans of every kind, and the label text tells anyone who reaches it to leave it empty.

Cached pages with stale tokens. If the form is served from a CDN cache, every visitor gets the same token, possibly hours old. Either exclude the form page from caching, or fetch a fresh token with a small script and fall back to “suspicious → challenge” when it is missing.

Challenges that fail accessibility. A visual puzzle as the escalation step fails WCAG 1.1.1 and, on authentication flows, 3.3.8. Use a challenge service that offers invisible or non-cognitive verification, and always provide an alternative such as email confirmation.

Measuring Whether the Honeypot Works

A honeypot is invisible to users, which also makes it invisible to you unless you measure it. Count verdicts per form — human, suspicious, bot — and alert on changes. A healthy contact form shows a steady trickle of bot verdicts and a very small number of suspicious ones. If bot verdicts drop to zero while spam reaches your inbox, the spammers have adapted to your field name; rename it. If suspicious verdicts spike, check whether a browser update changed autofill behaviour or a cache is serving stale tokens before tightening anything. And sample the discarded bot submissions occasionally — without storing them long-term — to confirm that no real messages are being thrown away. A false positive on a honeypot is silent by design, so this sampling is the only way to find one.

Choosing an Escalation Challenge

When a submission is suspicious, the escalation should cost a bot far more than a human. Invisible challenge services (such as Cloudflare Turnstile) run browser checks and proof-of-work without asking the user anything most of the time, and verify with your server through a secret key; that verification must happen server-side, because a token that is only checked in the browser can be skipped like any other client check. For users without JavaScript, the fallback is a simple, accessible question or an email confirmation loop — “We’ve sent you a link to confirm your message” — which is slower but never excludes anyone. Keep the user’s input across the challenge; losing a long message to a spam check is the fastest way to lose a real customer. The broader point about never trusting a check the browser performed is made in why client-side validation is not security.

Submission verdicts and escalation A submission is assessed as human, suspicious or bot; suspicious submissions go through a challenge that can promote them to human, while bots receive a fake success. assessing human suspicious challenged bot (fake success) signals clean show challenge too fast or stale token challenge verified server-side honeypot or forged token
Only the suspicious middle ever sees friction, and passing the challenge rejoins the normal validation path.

Frequently Asked Questions

What is a honeypot field in a form?

An extra input that humans never see or reach but automated bots fill in because they complete every field. If it arrives with a value, the submission is almost certainly automated.

How do I hide a honeypot field without affecting screen reader users?

Position it off-screen, put aria-hidden="true" on its wrapper, give the input tabindex="-1" and autocomplete="off", and label it "Leave this field empty" in case anyone does reach it.

How fast is too fast for a form submission?

Two to three seconds from page render is a common threshold. Treat faster submissions as suspicious and show a challenge rather than rejecting them, because password managers can fill forms very quickly.

Should a detected bot get an error message?

No. Return the normal success response so the bot learns nothing it can adapt to, and discard the submission quietly.

← Back to Validation Security and Abuse Prevention