BETA
Skip to content

Spam Protection ​

Krafter Forms scores every submission against eleven heuristic signals, keeps a honeypot check that fires regardless of the score, rate-limits per IP, and temp-bans IPs that keep tripping the heuristics. Cloudflare Turnstile is supported for forms that want a CAPTCHA, and optional — the default protection needs no third-party service.

Honeypot Fields ​

A honeypot is a hidden form field that is invisible to real users but gets filled in by automated bots. When a submission includes a non-empty honeypot value, it is flagged as spam.

How It Works ​

  1. Add a hidden field named _honeypot or _gotcha to your form.
  2. Hide it from real users using CSS.
  3. Bots that crawl and fill in all fields will populate the honeypot.
  4. Krafter detects the filled honeypot and flags the submission as spam.

HTML Example ​

html
<form action="https://app.krafter.dev/f/contact" method="POST">
  <!-- Honeypot field: hidden from humans, visible to bots -->
  <div style="position: absolute; left: -9999px;">
    <input type="text" name="_honeypot" tabindex="-1" autocomplete="off">
  </div>

  <input type="text" name="name" required>
  <input type="email" name="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

WARNING

Do not use display: none or visibility: hidden to hide the honeypot field. Some bots detect these CSS properties and skip hidden fields. Use off-screen positioning (left: -9999px) instead.

What Happens to Spam Submissions ​

When a honeypot is triggered:

  • The submission is still stored in the database.
  • The is_spam flag is set to true.
  • The spam_score field is populated.
  • Spam submissions are hidden by default in the dashboard and API responses.
  • Email notifications and webhooks are not triggered for spam submissions.

The server still returns a 201 response so bots do not know they were detected:

json
{
  "ok": true,
  "id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}

Heuristic Scoring ​

Every submission is scored against eleven signals. Each contributes a weight; the weights are summed, capped at 1.0, and compared against a threshold. A submission at or above the threshold is flagged is_spam.

SignalWeightFires when
honeypot1.0_honeypot or _gotcha is non-empty. Flags regardless of the threshold, and even when heuristics are disabled
bot_user_agent0.8The User-Agent names a non-browser client (curl, python-requests, okhttp, playwright, bot, crawler, …). Alone this exceeds the default threshold
fake_email_domain0.7The email domain publishes neither an MX nor an A record. See MX checking
gibberish_name0.6The name field looks machine-generated (long consonant runs, too few vowels, erratic capitalisation)
gibberish_message0.6The message field looks machine-generated by the same test
gmail_dot_abuse0.5A gmail.com / googlemail.com address with 4+ dots in the local part — Gmail ignores dots, so this fakes distinct addresses
disposable_email0.5The domain is a known throwaway provider (mailinator, guerrillamail, yopmail, …)
url_stuffing0.5The message contains more than 3 links
too_fast_submit0.5The form was submitted under 2 seconds after render. Requires _ts — see timing
missing_user_agent0.4The request carries no User-Agent at all
too_short_message0.3The message is shorter than min_message_length

The signals that fire are recorded on the submission, so the dashboard shows why something was flagged.

Only three fields are inspected

The heuristics read the fields named name, email and message. Name your fields that way and the scoring has something to work with; name them full_name and enquiry and most signals stay silent.

Configuring the heuristics ​

Set spam_protection.heuristics when creating or updating a form:

bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "spam_protection": {
      "heuristics": {
        "enabled": true,
        "threshold": 0.7,
        "min_message_length": 10,
        "check_mx": true
      }
    }
  }'
KeyTypeDefaultMeaning
enabledbooleantrueWhen false, signals are still recorded but only the honeypot flags
thresholdnumber0.7Score at or above which a submission is spam. Lower catches more and produces more false positives
min_message_lengthinteger10Below this the too_short_message signal fires
check_mxbooleantrueWhether to resolve the email domain — see below

MX checking ​

fake_email_domain resolves the email domain's MX records, so it is the only signal that leaves the server. The cost is up to 2 seconds for a domain not already cached, then an hour of in-memory hits for that domain. Set check_mx: false if that latency matters more to you than the signal.

It is deliberately fail-open: a resolver that times out or errors never flags, so a flaky network cannot start rejecting real users. Only a domain that authoritatively publishes no MX and no A record scores.

Submit timing ​

too_fast_submit needs your page to tell Krafter when it rendered. Stamp the render time in milliseconds into a hidden _ts field:

html
<input type="hidden" name="_ts" id="ts">
<script>document.getElementById('ts').value = Date.now();</script>

Without _ts the signal is silently inactive — it is opt-in, and there is no way for the server to infer the render time.

Cloudflare Turnstile ​

For forms that want a real CAPTCHA, Krafter verifies Cloudflare Turnstile tokens server-side. Enable it per form with your Turnstile secret key:

bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "spam_protection": {
      "turnstile": {"enabled": true, "secret_key": "0x4AAAAAAA..."}
    }
  }'

The secret is stored encrypted at rest and is never returned by the API — reads come back with "secret_key": "[REDACTED]".

Add the widget to your form with your site key (the public half):

html
<form action="https://app.krafter.dev/f/contact" method="POST">
  <input type="email" name="email" required>
  <textarea name="message" required></textarea>

  <div class="cf-turnstile" data-sitekey="0x4AAAAAAA..."></div>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

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

The widget posts a cf-turnstile-response field (cf_turnstile_response is also accepted). A submission that fails verification is rejected outright:

json
// 403 Forbidden
{ "error": "captcha_failed", "reason": "verification_failed" }

Note the difference from the heuristics: a heuristic flags and stores, Turnstile rejects. A missing or invalid token means the submission never lands.

Repeat-Offender IP Bans ​

An IP that trips the spam heuristics 8 times within an hour is temp-banned for 24 hours. Banned requests get the same 201 {"ok": true} a real submission gets, so a bot cannot tell it has been detected — but nothing is stored, and no notification or webhook fires.

This is not a rate limit and does not replace one: the rate limit caps volume regardless of intent and catches legitimate users too, while the banlist only picks up sources that keep being flagged as spam. It is not configurable per form, and the counts are per node.

Rate Limiting ​

Two independent rate-limit layers protect every public form submission endpoint. Both are tracked in-memory using ETS on each Krafter node.

LayerScopeDefaultConfigurable429 body
Platform pipelineAll /f/* traffic per IP, per node200 requests / 60sNo (platform-wide ceiling on every /f/* route){"error": "Rate limit exceeded", "retry_after": <seconds>} plus X-RateLimit-* and Retry-After headers
Per-form limiterPer (form, IP), per node10 requests / 60sYes — settings.rate_limit on the form{"error": "rate_limited"}

The platform pipeline cap is applied first by KrafterWeb.Plugs.RateLimit in the :public_rate_limit pipeline, so raising settings.rate_limit above 200/min has no effect — the pipeline cap kicks in earlier on busy IPs.

The per-form rate-limit counters live in an in-memory ETS table on each Krafter node, kept by Krafter.RateLimit (Hammer, fixed window). Counts are not shared across nodes, so with N replicas each limit is effectively limit x N — Krafter runs one replica today and logs a warning at boot if that stops being true.

The two layers are distinguishable by their JSON body shape and by whether X-RateLimit-* headers are present (they are only set by the platform pipeline, not by the per-form limiter).

Configuring Rate Limits ​

You can customize the rate limit per form via the form settings. Set settings.rate_limit when creating or updating a form:

bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "rate_limit": 5
    }
  }'

This sets the limit to 5 submissions per IP per 60-second window for this form.

settings.rate_limit must be a positive integer; anything else is rejected with a 422. There is no upper bound, but the platform pipeline cap of 200 req / 60 s per IP applies on top, so values above ~200 do not buy you anything in practice. If you do want to lift the per-form ceiling above the platform cap, contact support — it requires a pipeline-level change, not a settings tweak.

rate_limit must be a positive integer

rate_limit: 0 does not mean "unlimited" and no longer means "one per window" either — since the limiter moved to Hammer, a limit of 0 rejects the first request and every one after it, so the form accepts nothing at all. Negative values behave the same way.

The API rejects 0, negative numbers, floats and strings at write time — {"rate_limit": "5"} used to be accepted and then answered every submission with a 500, because the limiter takes an integer.

If a form of yours already has rate_limit: 0 stored, its intake was closed and is now open again at the default 10/60s: the ingest treats an unusable stored value as absent rather than passing it on, and logs a warning naming the form. Set the limit you actually want.

To run a form effectively unrate-limited at the per-form layer, set a high integer (e.g. 1000000); the platform pipeline cap (200 req / 60 s per IP) still holds the line. Removing the rate_limit key entirely falls back to the default of 10/60s, not "unlimited".

Managing Spam Submissions ​

Viewing Spam in the Dashboard ​

By default, spam submissions are hidden in the dashboard. Use the filter controls to show spam entries.

Filtering via API ​

Use the is_spam query parameter to filter submissions:

bash
# List only spam submissions
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=true" \
  -H "Authorization: Bearer kr_live_abc123def456"

# List only non-spam submissions (default)
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=false" \
  -H "Authorization: Bearer kr_live_abc123def456"

Marking / Unmarking Spam ​

You can manually mark a legitimate submission as spam or unmark a false positive:

bash
# Mark as spam
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{"is_spam": true}'

# Unmark spam (false positive)
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{"is_spam": false}'

Best Practices ​

  • Always include a honeypot field in your HTML forms. It catches the majority of automated spam with zero friction for real users.
  • Name your fields name, email and message where that fits. Most heuristic signals read exactly those three; other names leave them silent.
  • Stamp _ts. Two lines of markup buy you the too_fast_submit signal.
  • Tune the threshold from your own spam queue, not in the abstract. Lower it if spam is getting through, raise it if real submissions are being flagged.
  • Reach for Turnstile when the heuristics are not enough. It rejects rather than flags, so it stops abuse the score lets through — at the cost of a widget in front of your users.
  • Keep rate limits reasonable. The default of 10 per minute works well for contact forms. Increase it for high-traffic forms like newsletter signups.
  • Review spam periodically. Check the spam queue for false positives and unmark them if needed.
  • Validate on the server, not only in the browser. required in HTML is advisory; see server-side validation for the setting that enforces it.

Built by Krafter Studio