DEV Community

I Used a Honeypot Instead of a CAPTCHA on Our Quote Form

Antony Nyagah on October 09, 2026

At work, I'm rebuilding a website for an organization that serves medical laboratories. The most important page on the new site is a form where a l...
Collapse
 
thomasbnt profile image
Thomas Bonnet •

Great idea!

Collapse
 
nyagah profile image
Antony Nyagah •

Thanks!

Collapse
 
furqan_ashraf profile image
Furqan Ashraf •

Honeypots are underrated, nice to see one used instead of a CAPTCHA. They catch the simple bots well. For the smarter ones that skip hidden fields, a second check on submit can help: look up the sender's IP and flag hosting, proxy or Tor traffic for review instead of blocking it. The fields an IP security lookup returns are listed here if useful (ipgeolocation.io/documentation/ip-security-api.html).

Collapse
 
nyagah profile image
Antony Nyagah •

Thanks, I'll look into flagging instead of straight out blocking IPs. Going to be looking into the ipgeolocation.io too.

Collapse
 
furqan_ashraf profile image
Furqan Ashraf •

Glad it helped! One more idea since your form already asks for town and country: compare the country they type with the country their IP comes from. A mismatch isn't proof of spam (VPNs and travel happen), but combined with a hosting or proxy flag it's a good reason to send that request for manual review. The IP Location API returns the country and the security data in one call (ipgeolocation.io/documentation/ip-location-api.html). Nice work keeping the form friction-free for real users!

Thread Thread
 
yuhehe profile image
Yuhe He •

The mismatch-as-review-trigger framing is right — score, don't block. From the other side of the pipe (I run crawlers from datacenter IPs constantly): a signal tier like that is what keeps honest automation out of the deny-list graveyard. One production note: cache the geolocation answer per IP for a day — these APIs rate-limit, and a quote form should never fail open or closed on a third-party lookup. Friction-free for humans, scored for machines, human eyeballs on the gray zone: that's the trinity.

Thread Thread
 
furqan_ashraf profile image
Furqan Ashraf •

Good call on caching per IP for a day. It also means the form still works when the lookup is slow, which is the part I hadn’t thought through. Agree that the gray zone should go to a person, not a block.

Thread Thread
 
yuhehe profile image
Yuhe He •

That's the whole philosophy in one line: the form stays friction-free for humans, scored for machines, and a person owns the gray zone. Ship it 🚢

Collapse
 
elijahbrown profile image
Elijah Brown •

Nice write-up, and faking the success page is a good detail. Alongside the honeypot I'd also reject addresses on domains that publish a Null MX (they accept no mail) or that don't resolve at all. Those pass every shape check and still never deliver a quote reply.

Collapse
 
yuhehe profile image
Yuhe He •

The DNS tier is the cheapest validation tier there is, with one fork in the road worth drawing explicitly, because the three cases feel similar and behave very differently:

  • Null MX (RFC 7505): the domain declared "no mail here" (0 .). Hard-reject is safe — it's the domain owner's own signed statement.
  • NXDOMAIN: address can't exist, typo in the typebox. Hard-reject, and you can even show a targeted "check the domain spelling" message.
  • Resolves, no MX records, has an A record: per RFC 5321 the MTA falls back to the A record, and this is normal for plenty of small orgs. I'd flag, not reject — labs in your exact audience run on one Gmail-for-Work domain with the website on a separate registrar, and a hard-reject there eats real quotes.

From the data side: the same three-tier split (declared / provable / inferable) is what makes a validation list survive contact with real customers — the first two tiers you can enforce, the third one you log and let humans overrule. The quote form's whole purpose is a human replying to a human; optimize the rejects to never eat a possible reply.

Collapse
 
yuhehe profile image
Yuhe He •

The Null MX check is my favorite kind of filter — it kills a whole class of fake with one standards-track lookup, no heuristics. From the bot side, the pair I'd run together is: Null MX / no-MX for the accept-no-mail domains, plus an A-record check on the website fields in the same quote form, because scraped company URLs are frequently parked domains that resolve, render a template, and forward nowhere. One deployment note from living behind flaky infrastructure: give the DNS checks a strict timeout budget (a few hundred ms, cached) and fail open on resolver failure — a spam filter that rejects mail-quote form submissions because its own resolver timed out punishes the lab manager, not the bot, and your logs will show it as a mysterious traffic dip on exactly the days your DNS was sad.

Collapse
 
jimoquinn profile image
Jim O'Quinn •

I like the idea of moving the field off the screen rather than using the hidden attribute. Did something similar because CAPTCHA seems to be more difficult to solve over time. The form is run through 9 checks, with several checks using the Neutrino API which costs us $10 per month and is worth every penny.

The 9 checks are in a Perl module (I know, I know) and you're welcome to have AI re-write the code and fix any bugs it finds. Here is the code as a text file: FormChecker.pm.txt. If you want the code that wraps around the FormChecker.pm module, I can provide as well.

Order of form validation

  1. Check local ban log to see if we've banned the IP before, if exists the validation fails
  2. Check the hidden input field named email, if blank continue, if not the validation fails
  3. Check the message input textarea for bad/spammy words, if exists the validation fails
  4. Check the phone number, if not a US number, then validation fails
  5. Check IP location, if not US, then validation fails
  6. Check IP ban log via API to see if IP is listed, if exists the validation fails
  7. Check the domain's reputation, if low, the validation fails
  8. Check the email's syntax and other attributes, if there are issues, validation fails
  9. Check the email against the domain's SMTP server, if rejected, validation fails
Collapse
 
nyagah profile image
Antony Nyagah •

Awesome. Will have a look at the FormChecker code and your advice is solid stuff.
Going to look into implementing all your suggestions 🫡

Collapse
 
jimoquinn profile image
Jim O'Quinn •

This sits in front of SendGrid and if spam was to bypass these filters and since SendGrid has no chill, we'd likely be booted from their platform. This form checking code is at least 6-8 years old.

Best of luck and if you have any questions, feel free to ask.

Collapse
 
mno_tao_236ab4649edf4cf9f profile image
alex.toolkit •

Nice write-up, and faking the success page is a good detail. One thing to watch with this pattern: Chrome can ignore autocomplete="off" on fields its autofill recognises, and form-filling extensions don't always skip an input that is only moved off-screen. Because the handler pretends the request went through, a real lab manager whose browser filled website would never find out their quote was dropped.

It might be worth logging honeypot hits (just a timestamp and user agent is enough) so a human-looking one stands out, or giving the field a name autofill has no reason to touch.

Collapse
 
nyagah profile image
Antony Nyagah •

Oh, I had not thought about form-filling extensions. I use one myself.
Thanks for the tips on resolving this.

Collapse
 
intelliflame profile image
Intelliflame •

Faking success is the right call — an error just teaches bots to retry. One layer that costs little: a render-to-submit time check (scripted submissions land in under a second; real lab managers don't), plus a per-IP rate limit as the backstop for anything that slips past the trap.