# Your anti-bot check only binds the clients that opt into it

> Our signup endpoint was used as an email relay. The honeypot that fixed it only ran on clients that sent the fields, and our own app sent none of them.

- Author: Chad Priest
- Published: 2026-09-12
- Canonical URL: https://blog.vodou.ai/anti-bot-signals-first-party-clients/
- Tags: ai-agents, security, architecture, api-design

---

You ship a signup endpoint. Then you ship a desktop app, a CLI, an installer, maybe an MCP server, and each one needs to create an account too. So each one gets a relay: a small server-side function that takes a form post and forwards it to the same public endpoint your web form uses.

Now somebody abuses the endpoint. You add a honeypot field and a submit-timing check to the browser form. Both of those live in the client. Your relays do not send them. You have not hardened the endpoint. You have hardened one of its clients, and published a list of which one to avoid.

That is the shape of what I spent 2026-09-03 on, and the part worth your time is not the honeypot. It is the question of what an absent field means.

## Two days as somebody else's email relay

Between 2026-09-01 and 2026-09-03, `app.vodou.ai`'s `/auth/register` was used to send mail. Registration triggers a confirmation email, the attacker controls the address and the display name, and the endpoint is a free outbound SMTP leg with our domain's reputation on it. Nothing was stolen. The endpoint just did its job at volume for someone else.

The server-side fix is unremarkable: per-IP limits, inbox-level email uniqueness (so plus-addressing and dots collapse to one inbox), a honeypot field a real form leaves empty, and a rejection for any signup completed faster than a person can type. Standard stuff.

Two of those four checks only exist if the client cooperates. A bot posting raw JSON does not fill the honeypot, because it never renders a form. It omits the field entirely. Same for the timing signal. Which means, in the default configuration, the honeypot and the timing check bound browsers and nothing else. The population they screen out is the population that was never the problem.

## The Console is a bare HTTP client wearing our own logo

The Vodou Console is the local web UI that ships with the product. Its onboarding form is a browser form, served from localhost, origin-locked. When you create an account there, the browser posts to the local gateway, and the gateway relays to `app.vodou.ai/api/auth/register` over plain HTTPS from a Node process.

From the server's point of view, that relay is indistinguishable from any other script. No JS challenge ran. No form was rendered. The [Agent Signup Index](https://talkshi.com/signups) puts the distinction plainly in its notes on AWS: a JS challenge is a filter where "a real browser passes, a bare HTTP client doesn't." Our own first-party relay is on the failing side of that line, and so is yours.

**Diagram: One endpoint, two kinds of client**

Two clients post to the same register endpoint: a browser form that carries honeypot and timing signals, and a first-party server relay that carries none

```text
  [Console form] --> [Local gateway relay (problem)]
  [Browser form] --sends both signals--> [POST /auth/register]
  [Local gateway relay (problem)] --sent neither--> [POST /auth/register]
  [curl with raw JSON (problem)] --sends neither--> [POST /auth/register]

  notes:
    Browser form: renders, so it can time itself
    Console form: also a browser
    Local gateway relay: server-side fetch
    POST /auth/register: honeypot + timing + per-IP

  The relay and the attacker presented the same evidence: none.
```

## The exemption I wrote first, and why it does not survive contact

My first version was an allowlist. The Console relay is ours, it is origin-locked, it is not the thing abusing us, so let it through without signals and require signals from everyone else.

That lasted until I wrote down what the allowlist would key on. A header we set. A user agent we choose. A shared token shipped inside a downloadable open-source product. Every one of those is a string an attacker copies out of the repo and pastes into their own request. An exemption for "the app" is an exemption anyone can claim by posting the same JSON, and worse, it is the *preferred* path once the unexempted path gets hard. I would have built a fast lane and labeled it.

So there is no exemption. The Console sends the evidence. `public/js/views/onboarding.js` stamps `Date.now()` when the credentials form renders and puts it in the payload, along with an always-empty `website` field. `src/api/onboarding.ts` forwards them to the register call as `form_rendered_at` and `website`. About twenty lines across four files, and most of the work was in one detail.

## `website: ''` is a different fact from no `website` at all

The obvious implementation is to omit the honeypot when it is empty. It is empty by definition, so why serialize it.

Because empty and absent answer different questions. An empty `website` means a client that knows the field exists and observed that nothing filled it. An absent `website` means a client that has never heard of the field. The first is evidence. The second is silence. If you conflate them, you can never turn the check on, because "absent" describes both an old build in the field and the attacker.

So the form sends `website: ''` explicitly, and the relay forwards each field only when the client actually supplied it:

```ts
const formRenderedAt = Number(req.body?.formRenderedAt);
const honeypot = typeof req.body?.website === 'string' ? req.body.website : undefined;

await vodouPostJson('/api/auth/register', {
  email, password, confirm_password: password,
  first_name: firstName, last_name: lastName, terms_accepted: true,
  ...(Number.isFinite(formRenderedAt) && formRenderedAt > 0 ? { form_rendered_at: formRenderedAt } : {}),
  ...(honeypot !== undefined ? { website: honeypot } : {}),
});
```

The relay never invents a signal it did not receive. An older installed Console, which sends neither field, keeps working exactly as before. That is what makes the server-side switch safe to flip later: the server can raise the bar from "signals are checked if present" to "signals are required" and know precisely who it is cutting off.

I also had to do it twice. The redesigned Console is at `/` and the previous one is at `/classic/`, both shipping for one release, and either can be the surface a given user is looking at. A signal added to one of them is a guard that half the population silently fails once the strict switch goes on.

**Diagram (beforeafter)**

Before, the relay sent no anti-bot fields and strict mode would need an exemption. After, it forwards timing and an empty honeypot and strict mode needs no exemption.

```text
  BEFORE: Before
    - browser form: honeypot + timing
    - Console relay: neither field
    - strict mode needs an exemption
    - the exemption is copy-pasteable

  AFTER: After
    - both surfaces stamp render time
    - both send website as empty string
    - relay forwards only what it got
    - absent still means old client
    - strict mode needs no exemption
```

## The property: every field your guard reads has a named client that writes it

Stated as something you can check, not as advice:

**For every signal a server-side guard evaluates, there exists an enumerated set of clients that send it, and the guard can distinguish "sent and negative" from "not sent at all."** Either that is true of your codebase or it is false. Go look.

It fails in two ways. It fails when a first-party client posts to the guarded endpoint without carrying the signal, because then your strictest setting cannot be enabled without breaking yourself. And it fails when the wire format collapses empty into missing, because then the flag you added for the future is one you can never turn on.

## Post your own signup endpoint with every client field deleted

Five minutes, nothing of ours involved.

First, ask the endpoint directly. Strip every field a browser would add and see what comes back:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  https://api.yourthing.com/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"probe-1@your-own-domain.test","password":"Correct-Horse-9","confirm_password":"Correct-Horse-9"}'
```

Passing looks like `400` or `422` with a body naming the missing signal. Failing looks like `201` and a confirmation email in a mailbox you own. If it is `201`, your honeypot is decoration: it is a field only honest clients volunteer.

Second, count your clients. The guard is only as good as the narrowest one:

```bash
grep -rn "auth/register" --include='*.ts' --include='*.js' --include='*.py' \
  --include='*.go' --include='*.php' . | grep -v node_modules
```

Every hit outside your web form is a lane that has to carry the same evidence. In our tree that grep returns the browser view twice and the relay once, and before this commit only zero of the three sent anything.

Third, if you store signup attempts, ask what the last month actually presented:

```sql
SELECT
  CASE WHEN form_rendered_at IS NULL THEN 'timing absent' ELSE 'timing sent' END AS timing,
  CASE WHEN honeypot IS NULL THEN 'honeypot absent'
       WHEN honeypot = ''   THEN 'honeypot sent, empty'
       ELSE 'honeypot filled' END AS pot,
  COUNT(*) AS n,
  SUM(CASE WHEN outcome = 'created' THEN 1 ELSE 0 END) AS accounts
FROM signup_attempts
WHERE created_at > datetime('now', '-30 day')
GROUP BY 1, 2;
```

Healthy output has a large `honeypot sent, empty` row and a small `honeypot filled` row that produced zero accounts. The row to worry about is `honeypot absent` with a nonzero `accounts`: those signups were never screened at all. And if you cannot write this query because you only log the verdict and not the evidence, that is the finding. You have no way to know how many of your accounts came through the unchecked door.

## What the bot-detection writing assumes about your caller

The current advice on agent traffic is good and it is aimed one layer above this. [Web Bot Auth](https://cheq.ai/blog/web-bot-auth-solves-identity-but-is-that-enough/) gives an automated client a cryptographic identity via RFC 9421 HTTP message signatures, and CHEQ's honest objection is that identity is not intent: knowing which agent is calling does not tell you what it will do. The [DEV writeup on agent signups](https://dev.to/layercall/how-to-handle-ai-agents-signing-up-to-your-product-102k) goes further and says the proxies are already lost, because a capable agent drives a real browser, arrives on a residential IP, and reads a real mailbox: "It is not defeating your checks; it is satisfying them." Stytch's guidance, [summarized for IAM teams](https://nhimg.org/community/agentic-ai-and-nhis/ai-agent-fraud-and-bot-detection-what-iam-teams-need-to-know/), says to bind automation policy to identity provenance.

All of it presumes the caller is trying to look like a browser. None of it covers the caller that is you. Your own relay does not sign requests, does not solve challenges, and does not render a form, and the reflex when it starts failing your new guard is to exempt it. That exemption is a bigger hole than the one you closed, and no amount of agent identity infrastructure helps, because the attacker is not pretending to be an agent. They are pretending to be your app, which publishes its own source.

## Still open: the strict switch is off, and that is the whole point of it

The server flag that requires client signals is not on. It cannot be until enough installed Consoles carry the new code, and the ones in the field right now do not. Until then the honeypot and the timing check bind only the clients that choose to be bound, which is exactly the weakness this post is about, sitting live in production with a known end date rather than a fix.

The flag is safe to flip precisely because the relay never fabricates the fields. But it is still a flag someone has to remember to flip, and a signal you can only verify by counting how many old clients are still calling. I would rather say that than claim the endpoint is now hard.

---

Source: [Your anti-bot check only binds the clients that opt into it](https://blog.vodou.ai/anti-bot-signals-first-party-clients/) by Chad Priest, from Building Vodou in Public.
