# Spam & Bot Protection

> **[Docs Overview](./overview.md)** / Spam & Bot Protection

How the platform defends the public contact/lead endpoint against spam and bots. The protection is **defense-in-depth**: a Cloudflare edge layer plus three application-layer checks in `app/api/contact/route.ts`. This doc explains how the layers fit together and why the ordering matters; for Turnstile provisioning and rate-limit operations, follow the cross-links.

---

## The layers

```mermaid
flowchart TB
    REQ[Form submission] --> CF[Cloudflare rate limiting<br/>10 req/min/IP]
    CF -->|allowed| TS[1 - Turnstile verification]
    TS -->|fail| REJECT[400 - real rejection]
    TS -->|pass| HP[2 - Honeypot check]
    HP -->|filled| FAKE[200 - silent fake success]
    HP -->|empty| TIME[3 - Timing check]
    TIME -->|too fast| FAKE
    TIME -->|ok| PROCESS[Validate + store lead]
```

| Layer               | Where                                           | Type              | On failure                    |
| ------------------- | ----------------------------------------------- | ----------------- | ----------------------------- |
| Edge rate limiting  | Cloudflare WAF                                  | Throttle per IP   | Request never reaches the app |
| 1. Turnstile        | `app/api/contact/route.ts` + `lib/turnstile.ts` | Bot challenge     | **Real** `400` rejection      |
| 2. Honeypot (`_hp`) | `app/api/contact/route.ts`                      | Hidden-field trap | **Silent** `200` fake success |
| 3. Timing (`_ts`)   | `app/api/contact/route.ts`                      | Submit-speed trap | **Silent** `200` fake success |

---

## Layer 1 - Cloudflare Turnstile (bot detection)

Invisible challenge-response; verified server-side **before any other check**. Implemented in `lib/turnstile.ts` (`verifyTurnstile(token, clientIP?, domain?)`), called at the top of the contact route. If `TURNSTILE_SECRET_KEY` is not configured, verification is skipped (returns success) so local/dev still works.

Because the platform serves 200+ custom domains and Cloudflare widgets cap their hostname list, custom-domain hostnames are managed automatically and, at scale, spread across a **widget pool**. That machinery is documented separately - don't duplicate it:

- **[TURNSTILE_SETUP.md](./TURNSTILE_SETUP.md)** - env vars, single-widget setup, hostname management, troubleshooting, test keys.
- **[TURNSTILE_POOL.md](./TURNSTILE_POOL.md)** - multi-widget pool architecture and provisioning for 200+ domains.

---

## Layer 2 - Honeypot (`_hp`)

A hidden form field rendered off-screen via CSS. Humans never see or fill it; bots that auto-fill every field do. If `payload._hp` is non-empty the request is **silently rejected with a fake `{ success: true }`** - see [Why silent success](#why-silent-success-on-honeypot--timing).

## Layer 3 - Submission timing (`_ts`)

The client sends `_ts`, the milliseconds elapsed since the form loaded. Submissions faster than `MIN_SUBMIT_TIME_MS = 3000` (3 seconds) are treated as bots and **silently rejected with a fake success**. Humans take time to read and fill a form; instant submissions are almost always automated.

---

## Why Turnstile is verified first

This ordering is deliberate and load-bearing. Turnstile runs **before** the honeypot and timing checks so that an attacker who fails Turnstile learns nothing about the other defenses. If honeypot/timing ran first, a bot could probe which traps it tripped. By failing Turnstile loudly (`400`) and the others silently (`200`), the only information an attacker gets is "the captcha failed."

```ts
// app/api/contact/route.ts (abridged)
const turnstileResult = await verifyTurnstile(payload._turnstile, clientIP, turnstileHostname);
if (!turnstileResult.success) {
  return NextResponse.json({ success: false, errors: [...] }, { status: 400 }); // real
}
if (payload._hp) return NextResponse.json({ success: true });                    // silent
if (payload._ts !== undefined && payload._ts < 3000) return NextResponse.json({ success: true }); // silent
```

## Why silent success on honeypot & timing

Honeypot and timing failures return `{ success: true }` rather than an error. A bot that gets an error message learns its submission was flagged and can adapt (clear the hidden field, slow down). A fake success gives it no signal - it "succeeds" while the lead is discarded. These events are logged (`logger.info` with the client IP) so we can still observe spam volume.

---

## Client payload contract

The form (`app/components/contact-section.tsx`, via the `hooks/useTurnstile.ts` hook) sends three protection fields alongside the lead data:

| Field        | Meaning                      | Expected value |
| ------------ | ---------------------------- | -------------- |
| `_turnstile` | Cloudflare Turnstile token   | non-empty      |
| `_hp`        | Honeypot hidden field        | empty string   |
| `_ts`        | Milliseconds since form load | `>= 3000`      |

---

## Edge rate limiting

The application itself does **not** rate-limit `/api/contact` - rate limiting is handled entirely at the Cloudflare edge (see the `Note: Rate limiting is handled at the edge` comment in the route). At the edge, `/api/contact` is limited to ~10 requests/minute/IP and throttled requests never reach the application. Configuration and monitoring: **[RATE_LIMITING.md](./RATE_LIMITING.md)**.

---

## Related

- [LEAD_FORMS.md](./LEAD_FORMS.md) - the contact endpoint these defenses guard (config, dealer identification, email side effects)
- [TURNSTILE_SETUP.md](./TURNSTILE_SETUP.md) / [TURNSTILE_POOL.md](./TURNSTILE_POOL.md) - Turnstile provisioning
- [RATE_LIMITING.md](./RATE_LIMITING.md) - Cloudflare rate-limit rules and monitoring
- [DEVELOPER_OVERVIEW.md](./DEVELOPER_OVERVIEW.md) - developer onboarding entry point
