Contact-form spam control that keys on what bots actually do, not on what they write. No dependencies, no third-party service, no captcha, no markup of its own. Runs on Node, Bun, Deno, Cloudflare Workers and the Next.js edge runtime.
npm i @profullstack/form-guard
A honeypot is a hidden field that humans cannot see and bots fill in. It works only against a bot that renders your page.
Most contact-form spam does not. It POSTs at your handler directly, which means the hidden field is never in the request body at all, and a check of the form "is this field empty?" answers yes. The submission sails through a defence that is working exactly as designed.
The message that prompted this package arrived at a form with a functioning honeypot. Every header passed — SPF, DKIM, DMARC, ARC — because the site was mailing itself through its own provider. The cryptography was never the question. Nothing in the request had touched the page.
So the first check here is a proof-of-render token: a signed, timestamped value minted when the form renders and required when it submits. Skip the page, have no token, go nowhere. It also carries the moment it was issued, which gives you the fill time for free.
| Layer | Catches | On failure |
|---|---|---|
| Proof-of-render token | Direct-to-endpoint bots | drop — silently discarded |
| Fill-time floor | Instant submits | retry — a human is asked to resend |
| Honeypot | DOM-filling bots | drop |
| Per-IP rate limit | Floods | limited — 429 |
| Content scoring | Low-effort lead bait | flag — delivered, tagged |
The split matters. The first four key on things no human visitor does, so they can block. Content scoring only ever tags, because every signal it reads has an innocent explanation — real people write short messages, use mail.ru, and paste links. Nothing in the scoring layer can stop a message from reaching you.
drop reports success to the caller. Telling a bot which check caught it
is free tuning information for whoever runs it.
import { createFormGuard, tagSubject, provenanceBlock } from '@profullstack/form-guard';
export const guard = createFormGuard({
secret: process.env.FORM_GUARD_SECRET,
binding: 'contact', // ties tokens to this one form
brandTerms: ['acme corp'], // words a scraper echoes back
rateLimit: { max: 5, windowMs: 60 * 60 * 1000 },
});Render the form with a token:
const token = await guard.issue();
const { token: t, honeypot } = guard.fields(token);
<form action="/api/contact" method="post">
{/* your real fields */}
<input type="hidden" name={t.name} value={t.value} />
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }}>
<label>Website<input type="text" name={honeypot.name} tabIndex={-1} autoComplete="off" /></label>
</div>
<button type="submit">Send</button>
</form>Not using JSX? guard.hiddenHTML(token) returns both inputs as an
escaped HTML fragment.
Check on submit:
const verdict = await guard.check({ fields: body, headers: request.headers });
switch (verdict.action) {
case 'drop':
return ok(); // tell the bot it worked
case 'retry':
return error('Please try again.'); // stale or too fast
case 'limited':
return error('Too many messages. Try later.', 429);
default:
await send({
subject: tagSubject(`[contact] ${subject}`, verdict),
text: `${message}\n\n${provenanceBlock({ ...verdict, verdict })}`,
});
return ok();
}A flagged message arrives with [spam? 6] in the subject — filter on it —
and a provenance block naming the IP, user-agent, fill time and the
signals that fired.
Ship with requireToken: false first. Every submission is scored and
annotated, nothing is ever blocked. Watch the flagged mail for a few days,
confirm no real enquiry is being tagged, then flip it to true.
createFormGuard({ secret, requireToken: process.env.FORM_GUARD_ENFORCE === '1' });Any stable server-side string. It never reaches the client — only the signature does — so it does not need to be a managed secret, but it does need to be the same across every instance that serves the form, or a token minted by one box will be rejected by the next.
Rotating it invalidates tokens on pages currently open. Those users get
retry, not a lost message.
| Option | Default | |
|---|---|---|
secret |
— | required |
binding |
'' |
ties a token to one form |
tokenField |
'fg_token' |
rename to be less guessable |
honeypotField |
'website' |
rename to be less guessable |
minAgeMs |
3000 |
fill-time floor |
maxAgeMs |
7200000 |
token lifetime |
flagAt |
3 |
score at which a message is tagged |
brandTerms |
[] |
words a scraper echoes back |
requireToken |
true |
false to score without blocking |
rateLimit |
{max:5, windowMs:3600000} |
false to disable |
The default store is per-process memory: not shared between containers, reset on deploy. Fine for one box. Refused attempts count toward the window, so a flood keeps its own window full and recovering takes a full window of silence.
Pass a store with take(key, windowMs, now) and reset(key) to back it
with Redis or a Durable Object when you run more than one instance.
It does not stop a human being paid to fill in your form, and it does not stop a headless browser that renders the page — both get a valid token. Those land in the scoring layer, tagged rather than blocked, which is the correct place for a judgement call a machine should not be making alone.
MIT © Profullstack, Inc.