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?

the form, as both handlers serve it
<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: none is 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 with tabindex="-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.timingSafeEqual in Node.js and hash_equals in 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 IP intelligence docs: block a critical score, or a high one with recent abuse. It holds medium and high scores for review and accepts the rest.

Behind a proxy or load balancer, take the client address your own proxy saw, as our X-Forwarded-For guide explains. The handlers read that header only when 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");
CheckWhat it catchesWhat the form answers
Honeypot fieldBots that fill every inputThanks, and the message is dropped
Time checkScripts that post at once, forged tokensThanks, or a request to reload
Email checkBroken and throwaway addressesA request to fix the address
IP checkSenders with a record of abuseThanks, 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 open data whose worst offense was spam. Two in three were on residential lines, so a rule that blocks data centers alone would have missed them.

What the free API said about 150 spam addressesRandom IPv4 addresses whose worst category in FFraud's open data is spam, looked up on October 25, 2026
  1. Abuse in the last 30 days132
  2. On a residential line100
  3. In a data center48
  4. Flagged as a proxy15
  5. Flagged as a VPN11
  6. 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:

test run, October 26, 2026, 00:37 to 00:38 UTC
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 none

185.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 guide to relay addresses explains.

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 IP abuse check, or the email into the email checker.

Sources

  1. Inaccessibility of CAPTCHA: Alternatives to Visual Turing Tests on the Web, W3C, December 16, 2021
  2. OAT-017 Spamming, OWASP Automated Threats to Web Applications
  3. aria-hidden, MDN Web Docs, October 30, 2025
  4. HTML attribute: autocomplete, MDN Web Docs, August 27, 2026
  5. Crypto: crypto.timingSafeEqual, Node.js documentation
  6. hash_equals, PHP manual
  7. IP intelligence API, FFraud
  8. Email checks API, FFraud
  9. Tor bulk exit list, The Tor Project, October 25, 2026
  10. ip-fraud-database, confirmed-abusive.csv, build of 21:30 UTC, FFraud, October 25, 2026