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.
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.
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.
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.
Related Guides
- Validation Security and Abuse Prevention — where bot signals fit among other controls.
- Rate Limiting Async Validation Endpoints — volume controls to pair with honeypots.
- Axe-Core Accessibility Testing — confirming the honeypot is invisible to assistive technology.
- Progressive Enhancement Without JavaScript — keeping the no-script path working with these checks.