Skip to content

Spam protection

Keep bots out with honeypots, Cloudflare Turnstile with your own keys, Altcha, rate limiting, geo and disposable email filters and the AI spam filter.

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 Floods from one IP address 429, nothing stored
Honeypots Bots that fill in every field Looks like it worked, nothing stored or sent
Cloudflare Turnstile Bots, checked in the visitor’s browser with your own Turnstile widget 422, nothing stored
Altcha Bots, slowed down by a proof-of-work puzzle that quickforms hosts itself 422, nothing stored
Geo filters Submissions from countries you block, or from outside the countries you allow Stored and held for review, nothing sent
Disposable email blocking Throwaway email addresses Stored and held for review, nothing sent
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.
  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. 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 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.

settingsjson
{
  "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 adds every honeypot for you, hidden. On a plain HTML form, add each one yourself and hide it from people:

index.htmlhtml
<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.

Cloudflare Turnstile

Cloudflare 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 (/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:

    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 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 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.

index.htmlhtml
<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.”
422 responsejson
{
  "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 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.
Switch Altcha onbash
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 includes altcha.challenge_url. It is null when Altcha is off and for sandbox previews.

With the embed widget

The embed widget 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 works the same way.

The hosted page 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.

    Get a challengebash
    curl https://YOUR-QUICKFORMS-HOST/f/FORM_ID/altcha
    200 responsejson
    {
      "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.

    altcha.jsjs
    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.

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.
settings.geojson
{
  "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.

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 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 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 for the base spam check, smart rules, the threshold and reviewing held submissions.

Type to search…

↑↓ navigate↵ selectEsc close