Honeypot Placement Strategies for AI Bots: Beyond CSS Hiding

For about fifteen years, the standard advice for stopping form spam was to add a hidden field, hide it with CSS, and reject any submission where that field was non-empty. It worked because the threat model was a script that fetched the HTML, found every <input>, and submitted them all. CSS was something the script never rendered.

That threat model is now one of three. The other two, DOM-aware automation and vision-based AI agents, have different blind spots. The legacy CSS hiding tricks fail against one of them and barely register against the other. If you ship a 2010-era honeypot in 2026, you’re catching the easy tail and pretending the hard middle doesn’t exist.

This is a working technical guide. We’ll go through the legacy stack and why it used to work, what each trick does and doesn’t catch today, and then a set of placement strategies that hold up against modern AI agents. Code examples throughout. No em dashes, no marketing.

The Three Populations You’re Defending Against

Before we get to placement, the threat model has to be precise. A “bot filling out my form” can mean three very different things, and a honeypot that catches one population may be invisible to the others.

1. Naive HTTP scrapers. A script (Python, Go, curl) that fetches your page, parses the HTML, fills every <input> in the DOM, and POSTs the form. Never executes JavaScript, never renders CSS. This is what classical honeypots were designed to catch, and they still do.

2. DOM-aware automation. Selenium, Playwright, Puppeteer, and a long tail of stealth wrappers driving a real browser engine. These do execute JS and apply CSS, and modern automation scripts explicitly skip fields where getComputedStyle(el).display === 'none' or el.offsetParent === null. They see your DOM but they read it like a parser.

3. Vision-based AI agents. OpenAI Operator, Claude Computer Use, Stagehand, and a growing number of agent frameworks that take a screenshot of the page, send it to a vision model, and act on coordinates. They see what a human would see (rendered pixels) and ignore everything that isn’t drawn.

Notice the inversion. Population 1 sees the most. Population 3 sees the least. Population 2 is in between, and is the one most actively cat-and-mousing with detection authors.

A honeypot catches a bot only when the bot fills the field and a human doesn’t. That’s a different physics problem for each population.

The Legacy Stack

These are the techniques that defined honeypot hiding from roughly 2008 to 2022. Each one works because it makes a field invisible to humans without removing it from the DOM. Here’s what each one does and where it lands today.

display: none

<input type="text" name="website" style="display: none">

The original. Removes the element from layout entirely. Humans never see it.

  • Naive HTTP scrapers: caught (they fill it).
  • DOM-aware automation: not caught. Stealth scripts filter display: none as the first pass.
  • Vision-based agents: not caught. The field is never rendered, so the vision model never sees it.

Verdict: still useful as a tripwire for the lazy population, basically free, but assume the sophisticated bots route around it.

visibility: hidden

<input name="website" style="visibility: hidden">

Like display: none but preserves the element’s box. Same outcome at the field level.

  • Naive HTTP scrapers: caught.
  • DOM-aware automation: usually caught by basic filters; some stealth tooling still bites.
  • Vision-based agents: not caught. Element occupies space but renders nothing.

Verdict: marginally better than display: none because some bots filter on display only. Still narrow.

Offscreen positioning (left: -9999px)

<div style="position: absolute; left: -9999px;">
  <input name="website">
</div>

The classic Akismet-style trick. The field is in the DOM, gets focus, gets submitted, but is rendered off the visible viewport. For a long time this was the most reliable honeypot pattern because it didn’t show up in obvious “is this hidden” filters.

  • Naive HTTP scrapers: caught.
  • DOM-aware automation: caught by older bots; modern stealth libraries explicitly check for elements outside the viewport bounding rect.
  • Vision-based agents: not caught. Off the screen means off the screenshot.

Verdict: better than display: none against careless DOM bots, irrelevant against vision agents.

clip and clip-path

<input name="website" style="
  position: absolute;
  width: 1px;
  height: 1px;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
">

The accessibility-friendly variant. The field is technically rendered at 1x1 pixel, focusable by screen readers, but visually invisible. This pattern is also used legitimately for skip-to-content links, which is the reason a lot of automation libraries don’t filter it.

  • Naive HTTP scrapers: caught.
  • DOM-aware automation: often not caught. Filters that look for display: none or visibility: hidden miss clip-path.
  • Vision-based agents: not caught. One pixel of rendered area is below the resolution of any screenshot the agent takes.

Verdict: this is the strongest of the legacy CSS tricks against DOM bots. Still loses against vision.

opacity: 0 plus pointer-events: none

<input name="website" style="opacity: 0; pointer-events: none;">

Renders the field but makes it transparent and unclickable. Less common because legitimate use cases are narrower.

  • Naive HTTP scrapers: caught.
  • DOM-aware automation: variable. Modern stealth tools detect zero-opacity inputs.
  • Vision-based agents: not caught. Zero opacity means zero pixels rendered.

Verdict: effectively the same trade as clip-path. Loses to vision.

Zero-size containers

<div style="height: 0; overflow: hidden;">
  <input name="website">
</div>

Same family. Field exists, container collapses, nothing renders.

  • Naive HTTP scrapers: caught.
  • DOM-aware automation: most stealth libraries catch the zero-bounding-rect parent.
  • Vision-based agents: not caught.

aria-hidden and autocomplete="off" decoys

<input name="website" aria-hidden="true" tabindex="-1"
       autocomplete="off" style="display: none">

Sometimes recommended as belt-and-suspenders. The accessibility attributes are real and helpful, but they don’t add detection power on their own. They modify how assistive tech treats the field; they don’t change what scrapers do.

The pattern across all of these

Every legacy CSS technique is, ultimately, “make the field invisible to the user but leave it in the DOM.” That worked because the threat model was a parser. The DOM was the truth.

Vision-based agents flipped the truth. The pixels are now the truth. Everything you hide from the pixels also hides from the agent. Honeypots that depend on “in the DOM but not on screen” are not honeypots against this population. They’re empty fields the agent ignores.

That’s the punchline of the legacy stack. The replacement is not “hide harder.” It’s “stop hiding and start placing.”

What Actually Works in 2026

The honeypot patterns that hold up against the harder populations share a common shape. Instead of removing the field from human perception via CSS, they place the field where a human is structurally unlikely to interact with it, while leaving it visible enough to a bot’s parser or vision model to be tempting. The bot fills it because it looks fillable. The human doesn’t because they have no reason to.

Six patterns we’ve seen hold up.

1. DOM-Order Placement Below the Submit Button

Put a normal-looking field, with a normal name, after the submit button in DOM order. Render it in normal CSS. Don’t hide it. Style it to look like a footer or a “do not fill” instruction.

<form action="/submit" method="post">
  <label>Email <input name="email" type="email"></label>
  <label>Message <textarea name="message"></textarea></label>
  <button type="submit">Send</button>

  <p style="font-size: 0.8em; color: #888;">
    Bot trap below. Real users should leave this empty.
    <input name="phone_secondary" autocomplete="off">
  </p>
</form>

Real users hit submit at the button and the form submits before they look at anything below it. They never see the field. DOM-aware bots that fill every input bite. Vision models that screenshot the rendered page may bite too if the field looks plausible, especially because LLM agents have a strong prior toward “if it’s a labeled form field, fill it.”

The cost is one extra field of cognitive noise for users who scroll past the submit button. In practice that’s a tiny share of legitimate traffic.

2. Semantic Lookalike Fields

This is the strategy that targets vision agents specifically. Pick a name and label that an LLM would expect a real form to have, but that your form does not in fact collect.

<label>Phone (secondary)
  <input name="phone_secondary" type="tel" autocomplete="off">
</label>

If your real form collects email and message, a vision agent reading the screenshot will see phone_secondary and infer it’s a real ask. The agent fills it. A human, if they see it (you can render it greyed out under the message field), reads “secondary” and skips it because they don’t have one.

The key constraint is that the field name must be plausible enough that the agent’s vision-to-text-to-action loop generates content for it. Things that look like:

  • phone_secondary, address_line_2, referral_code
  • coupon_code, survey_response, utm_source
  • attention_check_question, internal_id

Avoid honeypot names that are obvious traps in the broader corpus the model trained on (fax, website, url). Those have been bot-detection bait for a decade and modern agents have at least partial signal that they’re traps.

3. Shadow DOM Containment

Many automation libraries don’t traverse Shadow DOM cleanly, especially when the shadow root is closed.

const host = document.getElementById('trap')
const root = host.attachShadow({ mode: 'closed' })

const trap = document.createElement('input')
trap.name = 'company_size'
trap.autocomplete = 'off'
root.appendChild(trap)

Closed shadow roots are inaccessible from outside JavaScript. A standard automation library that does document.querySelectorAll('input') won’t find the field. A vision agent will see the rendered input and may fill it. A human, if you style the shadow content sensibly, won’t have a reason to.

This isn’t a silver bullet. Sophisticated tooling can drive into shadow roots via CDP, and Playwright in particular has gotten better at this. But the long tail of frameworks that were written before web components were taken seriously will miss it.

4. Behavioral Honeypots (Time and Sequence)

Instead of asking “was this field filled,” ask “was the form submitted in a way no human would submit it.” This is a honeypot at the interaction level rather than the field level.

A few specific signals:

  • The form was submitted in under 2 seconds from page load. Real humans almost never can.
  • The submit fired with no preceding pointermove, keydown, or focus event on any field.
  • The fields were filled in non-DOM-order (last field first) at uniform inter-field intervals.
  • The submit click had event.isTrusted === false, which is true for any synthetically dispatched event.
const formLoadedAt = performance.now()
let realInteraction = false

document.addEventListener('keydown', (e) => {
  if (e.isTrusted) realInteraction = true
}, { once: true })

document.addEventListener('pointermove', (e) => {
  if (e.isTrusted) realInteraction = true
}, { once: true })

form.addEventListener('submit', (e) => {
  const elapsed = performance.now() - formLoadedAt
  const trapTriggered = elapsed < 2000 || !realInteraction
  // Send trapTriggered as a signed token field with the POST.
})

This catches the entire population that submits forms via headless fetch() or programmatic event dispatch, including most agent frameworks running in their default configuration. It also catches sloppy automation in stealth mode if the operator forgot to fake real input events.

The tradeoff is that you must deliver a signed token to the server (so the bot can’t fake it), and you must accept some latency cost on legitimate fast typers.

5. Action Honeypots (Click-to-Reveal)

A field that doesn’t exist in the DOM until a real interaction creates it. Sounds backwards but is useful for high-value forms.

const trap = document.createElement('input')
trap.name = 'verification_code'
trap.autocomplete = 'off'

const msg = document.querySelector('#message')
msg.addEventListener('focus', () => {
  // Inject the trap into the form, but only after a real focus.
  document.querySelector('form').appendChild(trap)
}, { once: true })

A human focusing on the message field never knows the verification field exists. A bot that sets values without firing focus events never has a verification field to fill, so it fails a downstream check (your server requires the field to be present and empty). A bot that does fire focus events injects the field and may fill it.

This is more useful as a signal than as a hard gate, but it’s a strong signal because the only way to look right is to have a complete, ordered, realistic interaction sequence.

6. Endpoint-Level Decoys

The pattern that doesn’t depend on the DOM at all. Expose a never-linked, never-rendered endpoint that resembles a real one and treat any traffic to it as automated reconnaissance. Pair the visible form’s POST endpoint with a decoy:

POST /api/forms/contact            (real)
POST /api/v1/forms/contact         (decoy, never linked)
POST /api/internal/contact-legacy  (decoy, never linked)

Anyone hitting the decoy is either scanning the API surface for endpoints, replaying a stale config, or fuzzing. Real frontends never reach the decoy because nothing in your real frontend points there. We’ve covered the API-surface variant in depth in Endpoint Decoys and API Honeypot Protection.

The reason this matters in the AI context: vision agents that “click submit” go through whatever the page’s submit handler routes to. They don’t probe API surface. So endpoint decoys catch a different population (HTTP-level recon, leaked-config replays) than DOM honeypots do, and the two layers are complementary.

A Layered Honeypot Stack

If you’re building from scratch and you have a contact form, sign-up flow, or comment box that’s getting hit, this is the rough order to ship in:

1. Field-level (cheap, mostly catches population 1)
   - Real-looking field after the submit button
   - Semantic lookalike (phone_secondary or similar)
   - Optional: shadow DOM input

2. Behavioral (catches population 2 and parts of 3)
   - Time-on-form lower bound
   - isTrusted check on submit-path events
   - Field-fill order and inter-field timing

3. Action (catches stealth-y population 2 and 3)
   - Field that only exists after a real focus
   - Field name changes per page load, verified server-side

4. Endpoint (catches population 1 reconnaissance)
   - Decoy URLs paired with the real one
   - Honeypot fields in the JSON request shape

5. Server-side scoring
   - Aggregate every honeypot signal into one risk score
   - Respond symmetrically on accept and reject paths

The signals are individually weak. Combined, they push the cost of a successful submission high enough that the lazy populations move on and the careful populations show up in the logs you actually want to read.

A note on response symmetry. Don’t return “Thanks!” on success and “Looks like spam” on rejection. The bot’s operator uses your response to tune their tooling. Return the same HTTP status, the same body shape, and the same baseline timing whether the submission was accepted or trapped. Surface the real outcome to the legitimate user via a server-set cookie or a signed token the page reads after submit. We covered this pattern in Reverse Engineering Credential Stuffing Attacks and the same logic applies to honeypots.

Where AI Still Wins

The patterns above raise attacker cost. They don’t eliminate it. Two specific cases where a careful agent will still defeat honeypot-only defense.

Vision agent with semantic priors against you. A vision agent told to “fill out this contact form, only fields a real visitor would” can sometimes correctly skip your honeypot. Modern frontier models are trained on enough form data to recognize “this is a contact form with email and message” and treat extra fields skeptically. The defense here is to layer in something the model can’t reason its way around: behavioral telemetry, proof-of-work, or fingerprinting at the network layer.

Recorded human interaction replay. An attacker who records a real human submitting your form and replays the recorded pointermove and keydown traces against a fresh page load can defeat a lot of the behavioral honeypots above. The signals that survive replay are second-order: does the trace actually match the current page layout, do the keystrokes contain text that matches the visible field labels, is the timing distribution consistent with the user’s history. None of that is in scope for a honeypot guide; it’s the layer above honeypots.

Hand-tuned campaigns. Anyone with a budget to manually inspect your form, identify your honeypots, and write a custom config will get past honeypots specifically. Honeypots are an economic defense, not an absolute one. They make mass-scale, generic attacks unprofitable. They don’t stop targeted ones.

Pulling It Together

The story of honeypot placement over the last three years is a story about the truth-source flipping. For most of the web’s history, the DOM was what bots saw. Hide from the DOM, hide from the bot. For the next stretch, the rendered pixels are what AI agents see. Hiding fields from pixels also hides them from agents, which is the wrong direction.

The honeypot patterns that work in 2026 don’t try to hide. They place. They place fields where a human structurally won’t go (after the submit button), with names and labels that look plausible to an LLM (semantic lookalikes), behind interaction sequences a script doesn’t naturally produce (action honeypots), and outside the DOM tree the lazy bots traverse (shadow DOM, endpoint decoys). Then they read the patterns of interaction (timing, sequence, trust state) and feed it all into a single risk score.

That stack is what we ship in FCaptcha and what powers the WebDecoy form-protection layer. If you want to assemble it yourself the patterns above are enough to start. If you want it pre-assembled with the server-side scoring and the signed token pipeline already done, that’s what the product is for.

Either way, please retire the position: absolute; left: -9999px on your contact form. It’s not catching what it used to.

Further Reading

Want to see WebDecoy in action?

Get a personalized demo from our team.

Request Demo