Stop form spam without a CAPTCHA: a honeypot field plus free IP and email checks
Stop form spam without a CAPTCHA: an accessible honeypot field, a signed time check and free IP and email checks, with tested Node.js and PHP code.
A honeypot field is a form input that people never see but bots fill in. A form that arrives with it filled is spam. Add a signed time check next to it, because a script that posts the form at once gives itself away. Then check the sender's IP and email address on the server, to catch the spam that gets past both traps.
This recipe is for anyone who runs a contact, quote or signup form and gets bot spam. No step asks a person to solve a puzzle. It uses plain HTML, a Node.js handler with no packages and a PHP handler, all tested end to end.
#Why stop spam without a CAPTCHA?
A visual or audio CAPTCHA asks a person to prove they are human. W3C's review of CAPTCHAs found that they "fail to properly recognize users with disabilities as human". A distorted text test asks blind, visually impaired or dyslexic users to do the very task they are least able to do (W3C).
The same review lists honeypots among the alternatives and says the approach "should be considered". OWASP files form spam among its automated threats as OAT-017, Spamming.
#How do you add an accessible honeypot field?
<style>.hp { display: none; }</style>
<form method="post" action="/contact">
<label>Email <input type="email" name="email" required autocomplete="email"></label>
<label>Message <textarea name="message" required></textarea></label>
<p class="hp"><label>Leave this field empty <input name="website" tabindex="-1" autocomplete="off"></label></p>
<input type="hidden" name="t" value="SIGNED-TIME-TOKEN">
<button>Send</button>
</form>- Hide it with a stylesheet class. An element with
display: noneis not rendered, so screen readers skip it and the keyboard cannot reach it (MDN). - Give it a label anyway. "Leave this field empty" tells anyone who sees the page without its styles what to do.
- Keep aria-hidden off the input. MDN warns against
aria-hidden="true"on any element that can take focus, or on its parents. An input withtabindex="-1"can still take focus. - Turn autofill off.
autocomplete="off"asks the browser not to fill the field, though browsers may ignore it on login fields (MDN).
Some guides move the field off-screen instead, to fool bots that skip hidden fields. A field placed off-screen is still rendered, so it stays in the accessibility tree, where a screen reader can find it. No hiding method stops a bot written for your form, and that is what the server checks are for.
#How do you add the time check?
When the server renders the form, it writes the time into a token and signs it with a secret, using HMAC. On submit, it checks the signature and the age of the form. A plain timestamp field would not do, because a bot can write any time into it.
- Under 3 seconds: drop the message, and answer as if it arrived. Tune the value to your form, since a long form takes longer to fill in.
- Over 24 hours, or a bad signature: ask the person to reload the page.
- Compare in constant time:
crypto.timingSafeEqualin Node.js andhash_equalsin PHP do not leak timing information (Node.js and PHP docs).
#Where do the IP and email checks go?
They belong in the server handler that receives the form, after the honeypot and the time check. Both lookups use FFraud's free API, with no key. The handler follows the policy in the
Behind a proxy or load balancer, take the client address your own proxy saw, as our TRUST_PROXY=1, which the test below uses.
// A contact form protected by a honeypot field, a signed time check and FFraud's free IP and
// email checks. Node.js 18 or later, no packages. Run: FORM_SECRET=change-me node server.mjs
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.FORM_SECRET;
if (!SECRET) throw new Error("set FORM_SECRET to a long random string");
const MIN_SECONDS = 3; // faster than this is a script
const MAX_SECONDS = 24 * 3600; // older than this, ask for a reload
const API = "https://api.ffraud.com/public";
const cache = new Map(); // key -> { value, until }
function sign(time) {
return `${time}.${createHmac("sha256", SECRET).update(String(time)).digest("hex")}`;
}
const EMAIL = /^(?!\.)(?!.*\.\.)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?<!\.)@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
const looksLikeEmail = (email) => email.length <= 254 && email.indexOf("@") >= 1 && email.lastIndexOf("@") <= 64 && EMAIL.test(email);
function secondsSinceRender(token) {
const [time, mac] = String(token || "").split(".");
if (!/^\d{1,12}$/.test(time || "")) return null;
const want = Buffer.from(sign(time).split(".")[1], "hex");
const got = Buffer.from(mac || "", "hex");
if (got.length !== want.length || !timingSafeEqual(got, want)) return null;
return Math.floor(Date.now() / 1000) - Number(time);
}
async function lookup(key, url, init) {
const hit = cache.get(key);
if (hit && hit.until > Date.now()) return hit.value;
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(2000) });
const value = res.ok ? await res.json() : null;
if (value) cache.set(key, { value, until: Date.now() + 3600 * 1000 });
return value;
} catch {
return null; // the check is down or slow: fail open
}
}
const ipVerdict = (ip) => lookup(`ip:${ip}`, `${API}/ip/${encodeURIComponent(ip)}`);
const emailVerdict = (email) =>
lookup(`email:${email}`, `${API}/email/check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
function clientIp(req) {
// Behind your own proxy, take the address it saw; see the X-Forwarded-For guide.
if (process.env.TRUST_PROXY === "1") {
const hops = String(req.headers["x-forwarded-for"] || "").split(",").map((s) => s.trim()).filter(Boolean);
if (hops.length) return hops[hops.length - 1];
}
return req.socket.remoteAddress;
}
export async function judge(fields, ip) {
if (fields.website) return { status: "drop", why: "honeypot filled" };
const age = secondsSinceRender(fields.t);
if (age === null || age > MAX_SECONDS) return { status: "reload", why: "form expired or tampered" };
if (age < MIN_SECONDS) return { status: "drop", why: `sent after ${age} s` };
const email = String(fields.email || "").trim();
if (!looksLikeEmail(email)) return { status: "fix", why: "not an email address" };
const mail = await emailVerdict(email);
if (mail && mail.email_type === "temp") return { status: "fix", why: "throwaway email address" };
const v = await ipVerdict(ip);
if (!v) return { status: "accept", why: "IP check unavailable" };
if (v.risk === "critical" || (v.risk === "high" && v.recent_abuse)) return { status: "drop", why: `IP risk ${v.risk}` };
if (v.risk === "high" || v.risk === "medium") return { status: "review", why: `IP risk ${v.risk}` };
return { status: "accept", why: `IP risk ${v.risk}` };
}
const FORM = (token) => `<!doctype html><meta charset="utf-8"><title>Contact</title>
<style>.hp { display: none; }</style>
<form method="post" action="/contact">
<label>Email <input type="email" name="email" required autocomplete="email"></label>
<label>Message <textarea name="message" required></textarea></label>
<p class="hp"><label>Leave this field empty <input name="website" tabindex="-1" autocomplete="off"></label></p>
<input type="hidden" name="t" value="${token}">
<button>Send</button>
</form>`;
createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
return res.end(FORM(sign(Math.floor(Date.now() / 1000))));
}
if (req.method === "POST" && req.url === "/contact") {
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > 20000) return res.writeHead(413).end();
}
const fields = Object.fromEntries(new URLSearchParams(body));
const verdict = await judge(fields, clientIp(req));
console.log(new Date().toISOString(), clientIp(req), verdict.status, verdict.why);
if (verdict.status === "fix") return res.writeHead(400).end(`Please check your email address: ${verdict.why}.`);
if (verdict.status === "reload") return res.writeHead(400).end("This form expired. Please reload the page and send it again.");
if (verdict.status === "accept" || verdict.status === "review") {
// deliver fields.message here; mark it for a human look when the status is "review"
}
return res.writeHead(200).end("Thanks, we got your message."); // the same answer for dropped spam
}
res.writeHead(404).end();
}).listen(Number(process.env.PORT || 8080), "127.0.0.1");| Check | What it catches | What the form answers |
|---|---|---|
| Honeypot field | Bots that fill every input | Thanks, and the message is dropped |
| Time check | Scripts that post at once, forged tokens | Thanks, or a request to reload |
| Email check | Broken and throwaway addresses | A request to fix the address |
| IP check | Senders with a record of abuse | Thanks, then a drop or a review |
Dropped spam gets the same answer as a real message, so a bot learns nothing from the reply. A person with a broken or throwaway address gets a clear request to fix it.
#What do spam senders look like to the API?
We looked up 150 random addresses from FFraud's
- Abuse in the last 30 days132
- On a residential line100
- In a data center48
- Flagged as a proxy15
- Flagged as a VPN11
- Tor exit0
Source: FFraud free API and open data (build of 21:30 UTC), October 25, 2026
Under the policy above, 110 of the 150 would have been blocked and 40 held for review. None would have passed as clean. Every one carried the spam tag, and the open data held 255,174 such addresses in that build.
#What happens when the API does not answer?
Each lookup waits 2 seconds at most. If the API fails, times out or answers with an error, the handler accepts the message. The honeypot and the time check still run, so the form keeps its first line of defense.
Failing open suits a contact form, where a lost customer costs more than one spam message. Answers are cached for an hour, in memory in Node.js and in small files in PHP, so a busy form sends few lookups.
#How do you test it?
We started both handlers on this machine and posted to them the way bots and people do, on October 26, 2026:
Node.js 22.19.0, server.mjs
1. bot, sent at once HTTP 200 dropped: sent after 0 s
2. bot, honeypot filled HTTP 200 dropped: honeypot filled
3. tampered token HTTP 400 reload: form expired or tampered
4. team@@ffraud.com HTTP 400 fix: not an email address
5. test@mailinator.com HTTP 400 fix: throwaway email address
6. Tor exit 185.220.101.34 HTTP 200 dropped: IP risk critical
7. spam IP 45.132.227.60 HTTP 200 dropped: IP risk high
8. spam IP 9.246.15.55 HTTP 200 review: IP risk medium
9. 8.8.8.8 HTTP 200 accepted: IP risk none
10. 8.8.8.8 again HTTP 200 accepted: IP risk none (no new API call)
11. test@privaterelay.appleid.com HTTP 200 accepted: IP risk none
12. API down (fetch fails) accepted: IP check unavailable
PHP 8.3.25, contact.php on php -S
1. bot, sent at once HTTP 200 dropped: sent after 0 s
2. bot, honeypot filled HTTP 200 dropped: honeypot filled
3. test@mailinator.com HTTP 400 fix: throwaway email address
4. Tor exit 185.220.101.34 HTTP 200 dropped: IP risk critical
5. 8.8.8.8 HTTP 200 accepted: IP risk none
6. 8.8.8.8 again HTTP 200 accepted: IP risk none (from the file cache)
7. test@privaterelay.appleid.com HTTP 200 accepted: IP risk none185.220.101.34 is a Tor exit on the Tor Project's own list. The repeat request for 8.8.8.8 made no new API call: the Node.js cache answered it, and PHP's cache file kept its first time. Case 12 ran the handler with the network cut, and it accepted the message as designed.
Case 11 is a relay address from Apple's Hide My Email. The API answers alias for relay services and temp for throwaway inboxes, and the handlers ask only temp senders to fix their address. A real person reads a relay inbox, as our
Run the same cases against your own form with FORM_SECRET set. Put test addresses in X-Forwarded-For only while TRUST_PROXY=1, and turn that setting off in production unless your own proxy sets the header.
#Questions people ask
Do honeypot fields still work against spam bots?
Against bots that fill every input, yes, and they cost nothing. A bot written for your form can skip the field, which is why the time check and the IP and email checks follow.
Is a honeypot field accessible to screen reader users?
Yes, when a stylesheet hides it with display: none. That takes it out of the accessibility tree. Do not put aria-hidden on the input itself.
What if a bot posts straight to the form or resends the same values?
A post without a token, or with a forged one, fails the signature. A bot that reuses one real token passes the time check until it expires after 24 hours, and the IP and email checks still apply to it.
Should the honeypot be hidden with display:none?
For people, yes: it keeps the field away from screen readers and keyboards. A bot built to skip hidden fields will skip it too, and the server checks are there for those.
Does a honeypot stop people who send spam by hand?
No. A person never sees the hidden field, so they never fill it in. The IP check helps when they send from an address with a record of abuse.
How do I stop contact form spam without a CAPTCHA?
Layer cheap checks: a honeypot field, a signed time check, an email check for throwaway domains and an IP reputation check, as in the code above.
To look up one sender by hand, paste the address into the free