---
title: "Spam protection"
description: "Keep bots out with honeypots, Cloudflare Turnstile with your own keys, Altcha, rate limiting, geo and disposable email filters and the AI spam filter."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Spam protection

quickforms has several layers of spam protection. Each one catches something the others miss, and you can use any mix of them.

| Layer | What it catches | What happens |
| --- | --- | --- |
| [Rate limiting](#rate-limiting) | Floods from one IP address | `429`, nothing stored |
| [Honeypots](#honeypots) | Bots that fill in every field | Looks like it worked, nothing stored or sent |
| [Cloudflare Turnstile](#cloudflare-turnstile) | Bots, checked in the visitor's browser with your own Turnstile widget | `422`, nothing stored |
| [Altcha](#altcha) | Bots, slowed down by a proof-of-work puzzle that quickforms hosts itself | `422`, nothing stored |
| [Geo filters](#geo-filters) | Submissions from countries you block, or from outside the countries you allow | Stored and held for review, nothing sent |
| [Disposable email blocking](#disposable-email-blocking) | Throwaway email addresses | Stored and held for review, nothing sent |
| [Spam filter](https://docs.getquickforms.com/spam-filter/) | Spam that gets past the other layers, and messages that break your smart rules | Stored and held for review, nothing sent |

## How the layers fit together

A live submission goes through them in this order:

1. **Rate limiting.** Past 30 submissions a minute from one IP address to one form (by default), requests get a `429`.
2. **Honeypots.** If any honeypot has a value, quickforms responds as if the submission worked and drops it.
3. **Turnstile.** If the form has Turnstile switched on, the submission must carry a valid token, or it gets a `422`.
4. **Altcha.** If the form has Altcha switched on, the submission must carry a solved challenge, or it gets a `422`.
5. **Validation.** The submission is checked against the form's [schema](https://docs.getquickforms.com/schema/).
6. **Geo and disposable email filters.** A submission from a filtered country or with a disposable email address is held: it is stored, but no emails or webhooks are created until you mark it as not spam. The spam filter is skipped for it.
7. **Spam filter.** Jev runs the base spam check and the form's [smart rules](https://docs.getquickforms.com/spam-filter/#smart-rules). A submission that fails either is held in the same way.

The first four stop a submission before it is stored. The filters and the spam filter keep what they catch, so you can review it and release false positives. The person submitting gets the usual success response either way.

## Rate limiting

Submissions are rate limited per form and IP address: 30 a minute by default, set for the whole installation with `QUICKFORMS_RATE_LIMIT`. Past that the [submit endpoint](https://docs.getquickforms.com/submit/#rate-limits-and-statuses) responds `429` with "Too many submissions. Please wait a minute and try again."

## Honeypots

A honeypot is a text input that is hidden from people. Bots that fill in every field they find fill it in too. When any honeypot has a value, quickforms responds as if the submission worked (with `"id": null` in JSON) but stores and sends nothing, so the bot has no reason to try again. Honeypot values are never stored, even on genuine submissions.

### The main honeypot

Every form has a main honeypot. It is called `_gotcha` unless you rename it:

- per form with `settings.honeypot_field`
- for the whole installation with `QUICKFORMS_HONEYPOT_FIELD`

### Extra honeypots

Add up to 5 more with `settings.honeypot_fields`. Filling any one of them drops the submission.

```json title="settings"
{
  "honeypot_field": "_gotcha",
  "honeypot_fields": ["website", "fax"]
}
```

Some bots skip fields whose names start with an underscore, such as `_gotcha`. A honeypot with a realistic name looks like any other field, so those bots fill it in. The form editor suggests these names:

- `website`
- `company_website`
- `url`
- `fax`
- `address_line_3`
- `middle_name`

### Honeypot names

- Names can be up to 64 letters, numbers, dashes and underscores, optionally starting with one underscore.
- A honeypot can't share a name with one of the form's fields, because that would drop genuine submissions. Saving one that does gets a `422` on `settings.honeypot_fields`.
- Sending `settings.honeypot_fields` replaces the whole list rather than merging into it.

### Adding honeypots to your page

The [embed widget](https://docs.getquickforms.com/embed/) adds every honeypot for you, hidden. On a plain HTML form, add each one yourself and hide it from people:

```html title="index.html"
<div style="position:absolute; left:-10000px" aria-hidden="true">
  <input name="_gotcha" tabindex="-1" autocomplete="off">
  <input name="website" tabindex="-1" autocomplete="off">
  <input name="fax" tabindex="-1" autocomplete="off">
</div>
```

The names are listed in `honeypots` in the form's [public schema](https://docs.getquickforms.com/embed/#public-schema-json).

## Cloudflare Turnstile

[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) is a check that runs in the visitor's browser to tell people from bots. quickforms uses your own Turnstile keys: you create the widget in your Cloudflare account, and quickforms checks every live submission against it with your secret key.

### Set it up

1. **Create a widget.** In the Cloudflare dashboard, go to Turnstile and add a widget.
2. **Add your hostnames.** Add the hostname of every site the form is on. If you use the hosted [iframe embed](https://docs.getquickforms.com/embed/#iframe) (`/f/{form}/embed`), add the hostname of your quickforms app too, because the widget runs on that page.
3. **Copy the keys.** Cloudflare gives you a site key and a secret key.
4. **Paste them into the form.** Add both keys to the form's settings and switch Turnstile on. Over the API, send:

   ```bash
   curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
     -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
     -H "Accept: application/json" \
     -H "Content-Type: application/json" \
     -d '{"settings": {"turnstile": {"enabled": true, "site_key": "YOUR_SITE_KEY"}}, "turnstile_secret": "YOUR_SECRET_KEY"}'
   ```

5. **Add the widget to your page.** The embed widget does this for you. On a plain HTML form, add it yourself (see below).
6. **Test it.** Send a live submission from your site. See [testing in sandbox mode](#testing-in-sandbox-mode) for why sandbox submissions don't help here.

### Your keys

| Key | Where | Notes |
| --- | --- | --- |
| `settings.turnstile.enabled` | Form settings | `false` by default. |
| `settings.turnstile.site_key` | Form settings | Public. Returned in the form and in its public schema. |
| `turnstile_secret` | Top-level form attribute | Write-only. Encrypted at rest and never returned. |

- The form's `turnstile_secret_set` is `true` once a secret is saved, so you can tell whether one is there without seeing it.
- Sending a blank `turnstile_secret` keeps the saved one, so you only need to send it when you set or change it.
- Switching Turnstile on needs both keys. Without them, saving gets a `422` on `settings.turnstile.site_key` with "Add your Turnstile site key and secret key to switch Turnstile on."

### With the embed widget

When Turnstile is on, the form's public schema includes `turnstile.site_key`. The [embed widget](https://docs.getquickforms.com/embed/) loads Cloudflare's script, renders the widget above the submit button and sends its token with the submission. After a failed submission it resets the widget so the visitor can try again. The hosted iframe embed works the same way.

### With a plain HTML form

Add Cloudflare's script and a `cf-turnstile` element inside your form. The widget adds a hidden `cf-turnstile-response` input with the token, which is posted with the rest of the form.

```html title="index.html"
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="https://YOUR-QUICKFORMS-HOST/f/FORM_ID" method="post">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message"></textarea>

  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

  <button type="submit">Send</button>
</form>
```

If you post with `fetch` or send JSON yourself, include the token as `cf-turnstile-response`. The token is never stored with the submission.

### What happens when it fails

quickforms sends the token, your secret key and the visitor's IP address to Cloudflare's `siteverify` endpoint. JSON clients get a `422` with the error on `cf-turnstile-response`, and HTML posts get the hosted error page with a link back.

| When | Message |
| --- | --- |
| The token is missing | "Please complete the security check." |
| Cloudflare rejects the token | "The security check failed. Please try again." |
| Cloudflare can't be reached | "The security check could not be verified. Please try again." |

```json title="422 response"
{
  "message": "Please complete the security check.",
  "errors": { "cf-turnstile-response": ["Please complete the security check."] }
}
```

Turnstile fails closed: if Cloudflare can't be reached, the submission is rejected rather than let through, because a form with Turnstile on is meant to keep bots out. Nothing is stored for a rejected submission.

### Testing in sandbox mode

Sandbox submissions skip Turnstile, because they never send anything. That covers submissions with the sandbox key, test submissions from the dashboard, the API and the MCP `send-test-submission` tool, and the embed with `data-sandbox`. The public schema has `"turnstile": null` for sandbox previews, so the embed doesn't render the widget there.

To check that the widget and your keys work, send a live submission from a site whose hostname is on your Turnstile widget.

## Altcha

[Altcha](https://altcha.org/) is a proof-of-work check. The visitor's browser solves a small SHA-256 puzzle before submitting, which is quick for one person but costly for a bot sending thousands. quickforms issues and checks the puzzles itself, so no third party sees the visitor and there are no keys to set up.

### Settings

| Key | Default | Notes |
| --- | --- | --- |
| `settings.altcha.enabled` | `false` | Switches Altcha on. |
| `settings.altcha.max_number` | `100000` | How hard the puzzle is: the highest number the browser may have to try. Between `1000` and `1000000`. |

```bash title="Switch Altcha on"
curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"altcha": {"enabled": true}}}'
```

When Altcha is on, the form's [public schema](https://docs.getquickforms.com/embed/#public-schema-json) includes `altcha.challenge_url`. It is `null` when Altcha is off and for sandbox previews.

### With the embed widget

The [embed widget](https://docs.getquickforms.com/embed/) fetches a challenge when it loads, solves it in the background and sends the solution with the submission. After a failed submission it fetches a new one. The hosted [iframe embed](https://docs.getquickforms.com/embed/#iframe) works the same way.

The [hosted page](https://docs.getquickforms.com/hosted-forms/) does too. It solves the challenge in a Web Worker so the page stays responsive, and solves a fresh one after each submission.

### With your own code

1. **Get a challenge.** `GET /f/{form}/altcha` returns a fresh challenge. It responds `404` when the form doesn't have Altcha switched on, and is limited to 120 requests a minute per IP address.

   ```bash title="Get a challenge"
   curl https://YOUR-QUICKFORMS-HOST/f/FORM_ID/altcha
   ```

   ```json title="200 response"
   {
     "algorithm": "SHA-256",
     "challenge": "9f2c...",
     "maxnumber": 100000,
     "salt": "Xk3...?expires=1793990400&form=FORM_ID",
     "signature": "5be1..."
   }
   ```

2. **Solve it.** Find the number `n` from `0` to `maxnumber` where the SHA-256 hex digest of `salt` followed by `n` equals `challenge`.
3. **Send the solution.** Base64-encode a JSON object with `algorithm`, `challenge`, `number`, `salt` and `signature`, and send it as the `altcha` field with the submission.

   ```js title="altcha.js"
   const c = await fetch('https://YOUR-QUICKFORMS-HOST/f/FORM_ID/altcha').then((r) => r.json());
   const sha256 = async (text) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))]
     .map((b) => b.toString(16).padStart(2, '0')).join('');

   let number = null;
   for (let n = 0; n <= c.maxnumber; n++) {
     if ((await sha256(c.salt + n)) === c.challenge) { number = n; break; }
   }

   const altcha = btoa(JSON.stringify({
     algorithm: c.algorithm, challenge: c.challenge, number, salt: c.salt, signature: c.signature,
   }));
   // Send `altcha` with the rest of the submission.
   ```

The solution is checked before validation and never stored with the submission.

### What happens when it fails

A challenge is signed by quickforms, belongs to one form, expires after 10 minutes and works once. JSON clients get a `422` with the error on `altcha`, and HTML posts get the hosted error page with a link back.

| When | Message |
| --- | --- |
| The solution is missing or can't be read | "Please complete the verification." |
| The solution is wrong, forged, expired, for another form or already used | "The verification failed. Please try again." |

Sandbox submissions skip Altcha, as they skip [Turnstile](#testing-in-sandbox-mode).

## Geo filters

Geo filters hold submissions by the visitor's country. Countries are two-letter ISO codes such as `GB` or `US`, in upper or lower case.

| Key | Default | Notes |
| --- | --- | --- |
| `settings.geo.mode` | `off` | `off`, `allow` (only the listed countries get through) or `block` (the listed countries are held). |
| `settings.geo.countries` | `[]` | Up to 250 two-letter country codes. Sending the list replaces it. |
| `settings.geo.unknown` | `allow` | What to do in `block` mode when the country isn't known: `allow` or `block`. In `allow` mode an unknown country is always treated as not allowed. |

```json title="settings.geo"
{
  "mode": "allow",
  "countries": ["GB", "IE"],
  "unknown": "block"
}
```

### Where the country comes from

quickforms reads the country from the `CF-IPCountry` header, which Cloudflare adds when your quickforms app is behind it. It is only read on Laravel Cloud or with `QUICKFORMS_BEHIND_CLOUDFLARE` set, because a visitor who can reach the app directly could send their own. A self-hosted installation behind a different edge can read another header with `QUICKFORMS_COUNTRY_HEADER`.

- The country is unknown when there is no header, the value isn't two letters, or it is `XX` or `T1` (Tor).
- When your own server forwards submissions with a team API key that has the `proxy` ability, it can pass the visitor's country in `X-Quickforms-Client-Country`. That header is ignored on requests without a valid `proxy` token for the form's team.
- The country is stored with the submission as `meta.country`.

### What happens to a filtered submission

It isn't rejected. The visitor gets the usual success response, and the submission is stored, marked as spam and held: no emails or webhooks are created until you mark it as not spam. The AI spam filter doesn't run on it. The reason is recorded in `classification.filters` and `classification.reasons`:

| When | Reason |
| --- | --- |
| `block` mode and the country is listed | `Country RU is blocked` |
| `allow` mode and the country isn't listed | `Country US is not on the allow list` |
| The country is unknown, and the mode is `allow` or `unknown` is `block` | `Country unknown` |

Review and release held submissions as described in [flagged submissions](https://docs.getquickforms.com/spam-filter/#flagged-submissions).

## Disposable email blocking

Switch on `settings.block_disposable_emails` to hold submissions that use a throwaway email address. It is `false` by default.

- Every email field is checked. An address matches when its domain, or any parent domain, is on the list. For example, `mail.guerrillamail.com` matches `guerrillamail.com`.
- The list is built in: `filters.disposable_domains` in `config/quickforms.php`. Self-hosted installations can edit it there.
- A matching submission is handled like a [geo filter](#what-happens-to-a-filtered-submission) match: stored, marked as spam and held, with the AI spam filter skipped. The reason is `Disposable email address (mail.guerrillamail.com)`, with the address's domain.

## The spam filter

The [spam filter](https://docs.getquickforms.com/spam-filter/) is the last layer. It reads what the submission says, so it can catch spam that gets past the other layers, and genuine messages that break your form's smart rules. See [spam filter](https://docs.getquickforms.com/spam-filter/) for the base spam check, smart rules, the threshold and reviewing held submissions.

Source: https://docs.getquickforms.com/spam-protection/index.mdx
