A honeypot field is a decoy form input that people should leave empty but simple bots often fill. Your server checks the field before saving the submission. A non-empty value is strong evidence of automation, so the server can silently discard the request without making a visitor solve a CAPTCHA.
I use honeypots as the first spam layer in Form Plume because they are cheap, invisible, and effective against unsophisticated form fillers. They are not proof that every empty submission came from a person. The layered contact form spam guide covers what should sit behind the trap.
For Form Plume setup and current defaults, use the spam protection reference. For more implementation guides, browse the Form Plume blog.
A honeypot works by giving bots one extra field
Many basic bots scan a page, find its inputs, and put a value in each one. A real visitor never sees the decoy field, so a normal submission leaves it blank.
The receiver applies a simple rule:
honeypot is empty -> continue normal validation
honeypot has a value -> return normal-looking success and discardThe check belongs on the server. A browser-only condition can be removed, skipped, or bypassed by posting directly to the endpoint.
OWASP includes hidden form fields among its honeypot controls, but also recommends layered defenses because any single control is brittle. That distinction matters: a honeypot catches bots that behave naively. It does not certify that everyone else is human.
Use a normal text input, not type="hidden"
This is the markup I recommend for a plain HTML contact form:
<form action="https://api.formplume.com/f/{public_slug}" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<div class="form-honeypot" inert>
<label for="company-website">Leave this field empty</label>
<input
id="company-website"
name="_gotcha"
type="text"
autocomplete="off"
>
</div>
<button type="submit">Send</button>
</form>.form-honeypot {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}Replace {public_slug} with your form's public slug. If you use another backend, keep the same pattern and change the endpoint and field name to match its contract.
Do not make the decoy <input type="hidden">. Hidden inputs are ordinary control fields, and a bot can trivially skip them by type. A text input moved off-screen still looks like a field to a basic form filler.
The inert wrapper keeps the decoy and its descendants out of the tab order and accessibility tree while the CSS moves it off-screen. MDN documents inert as disabling focus and assistive-technology exposure.
autocomplete="off" asks browsers and password managers not to fill the trap. MDN documents that off is only a hint, so test the deployed form with the autofill and password managers your audience actually uses. A decoy accidentally filled for a real person becomes a false positive.
Check the honeypot before storage or side effects
Here is a minimal Node 22 demo for exercising both honeypot paths locally. It uses only built-in APIs and writes accepted submissions to a local JSON Lines file. It intentionally omits production controls such as request-size limits, content-type checks, durable storage, and centralized error handling.
// server.mjs
import { appendFile } from "node:fs/promises";
import { createServer } from "node:http";
const port = Number(process.env.PORT ?? 3000);
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/contact") {
response.writeHead(404).end();
return;
}
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
const honeypotFilled = (form.get("_gotcha") ?? "").trim() !== "";
if (!honeypotFilled) {
form.delete("_gotcha");
await appendFile("submissions.jsonl", `${JSON.stringify(Object.fromEntries(form))}\n`);
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
}).listen(port, () => console.log(`Listening on http://localhost:${port}`));Run it with node server.mjs, then exercise both paths from another terminal:
rm -f submissions.jsonl
curl -i -d 'email=person@example.com&_gotcha=' http://localhost:3000/contact
curl -i -d 'email=bot@example.com&_gotcha=filled' http://localhost:3000/contact
wc -l submissions.jsonlBoth requests should return 200 with the same {"ok":true} body. The final command should report one line, because only the empty-honeypot request was stored.
Run this check before writing to the database, sending email, uploading files, firing a webhook, or incrementing usage. Otherwise the bot still consumes the expensive work you meant to avoid.
Return a normal-looking success response. A descriptive honeypot_failed error tells the bot exactly what to change on its next attempt. The decoy response does not need to create a real submission ID, but it should follow the same public response shape closely enough that the trap is not obvious.
Do not log the submitted honeypot value unless you have a specific diagnostic need. The value is untrusted input, and storing it adds data without improving the decision.
Avoid false positives from autofill and accessibility mistakes
| Mistake | What happens | Better choice |
|---|---|---|
| The field stays interactive | A keyboard or assistive-technology user can reach an unexplained input | Make the wrapper inert |
| Autofill populates the decoy | A legitimate submission looks automated | Use autocomplete="off" and test real autofill tools |
| CSS fails to load | The trap becomes visible | Keep a clear “Leave this field empty” label as a safe fallback |
| JavaScript alone enforces the rule | Direct POST requests bypass it | Check again on the server |
| The server returns a bot-specific error | Attackers learn the detection rule | Return a normal-looking success |
Off-screen positioning keeps a conventional text control in the document for simple parsers. It is still only an implementation choice, not a security boundary. A capable browser bot can inspect either technique.
Honeypots stop simple bots, not every spammer
The trap will still miss:
- Bots configured to ignore known names such as
_gotcha - Headless browsers that inspect styles, labels, or visibility
- Scripts that post directly with only expected field names
- Human spammers
- Automation that learns from your response behavior
If you control the receiver, or your backend explicitly supports a custom honeypot name, changing the field name can catch bots hard-coded to skip a popular convention. Form Plume currently requires _gotcha. Obscurity only buys time, so do not make the name a secret your spam system depends on.
When the trap stops enough noise, leave it alone. When abuse gets through, add a signed time-trap token, server-side rate limits, and content scoring. Use CAPTCHA as a later step-up for persistent browser automation, not as the automatic replacement for a lightweight field.
Form Plume discards filled _gotcha requests before side effects
Form Plume reserves _gotcha as a special form field and recognizes it as its honeypot field. If _gotcha is non-empty, JSON clients receive a normal-shaped 200 response with a decoy ID. Native HTML submissions receive a 303 to a request-level _redirect or _next, or to /thanks/{slug} when neither is supplied. Because this branch runs before the form lookup, it does not use a success redirect saved in the form's settings. No submission is created.
The handler then exits before creating a submission, storing direct-upload objects, linking presigned files, metering usage, or enqueueing notification and webhook processing.
That behavior is deliberate. A real visitor gets no challenge, and the bot does not receive a useful rejection signal. The honeypot setup reference keeps the current product contract in one place.
I still pair it with the time trap and rate limits. The honeypot catches careless bots. The other layers can catch some clients smart enough to leave the field alone.
Before publishing your form:
- Add the decoy text input and keep it out of sight, keyboard order, and assistive technology.
- Submit once with the field empty and confirm the real entry is stored.
- Submit once with the field filled. Confirm you receive the normal success response, but no dashboard entry, notification, or webhook appears. If the form accepts files, confirm no attachment appears because no submission was created.
- Test autofill and a password manager, then add timing and rate controls if spam still gets through.
