Skip to content
Menu
Search
Browse documentation & this page

Developer Guide

Form captcha and spam protection

Put Cloudflare Turnstile (or another captcha) in front of your forms, and understand the protections that run whether or not you do.

A form is an anonymous way to make your site store data and send email. That makes it the first thing a spam bot finds. Cendia protects every form in layers, and this guide covers all of them: the ones that are always on, the captcha you add, and the settings you reach for during an attack.

The short version: create a Turnstile widget, set three values, and add a Captcha element to each public form. Everything else in this guide explains what that buys you and what to do when something doesn't work.

What protects a form

Layer On by default Stops
Rate limit Yes One visitor posting one form more than 8 times in 5 minutes.
Honeypot Yes Bots that fill in every field, including one no person can see.
Minimum fill time Yes Bots that post within 2 seconds of the form appearing.
Captcha Only on forms with a Captcha element Scripted posting. Turnstile also stops attackers who rotate through many IP addresses.
Hourly email cap Yes (60 per form) A form sending unlimited email, however many addresses post.
Visitor confirmation rules Yes Your site being used to email strangers. See The visitor confirmation email.

The honeypot and fill-time checks discard a submission silently: the bot sees the ordinary success message, and nothing is stored or sent. A clear refusal would tell the bot's author which rule to work around.

Why the built-in puzzle is not enough

With no captcha configured, a Captcha element asks a short sum ("What is 4 + 7?"). It keeps a fresh site working without an account anywhere, and each puzzle can be answered only once. But a script can do arithmetic, so the puzzle only slows a bot down.

A real captcha matters most because of the visitor confirmation email. Cendia sends one to an anonymous visitor only when a vendor captcha has verified them, and the puzzle never counts. If your forms email visitors, you need a real captcha.

Add Cloudflare Turnstile

Turnstile is free, usually invisible to people, and not tied to the visitor's IP address, so an attacker gains nothing by rotating through proxies. It ships with Cendia.CMS: there is no package to add and no code to write, only the configuration below. Until it is configured, forms keep using the puzzle.

1. Create a Turnstile widget

In the Cloudflare dashboard, open Turnstile and add a widget:

  • Hostnames: list every hostname the form is served on. Add both example.com and www.example.com if your site answers on both. The widget shows an error on any hostname not in the list.
  • Widget mode: Managed is the usual choice.

Copy the site key and the secret key. The site key is public and appears in your pages. The secret key is a password.

2. Configure the hosted site

Set these three as environment variables on the host. All three are needed.

Variable Value
Cendia__Forms__Captcha Turnstile
Cendia__Forms__Turnstile__SiteKey The site key from step 1
Cendia__Forms__Turnstile__SecretKey The secret key from step 1

The double underscore is how .NET spells : in an environment variable, so these are Cendia:Forms:Turnstile:SiteKey and so on. Never put the secret key in appsettings.json.

To try it on your own machine, use Cloudflare's test keys. They always pass and work on localhost, where your real keys won't unless you listed it as a hostname:

dotnet user-secrets set "Cendia:Forms:Captcha" "Turnstile"
dotnet user-secrets set "Cendia:Forms:Turnstile:SiteKey" "1x00000000000000000000AA"
dotnet user-secrets set "Cendia:Forms:Turnstile:SecretKey" "1x0000000000000000000000000000000AA"

Restart the site after changing any of these.

3. Add a Captcha element to the form

Configuration alone protects nothing: a captcha only runs on a form that has a Captcha element. In the CMS, open the form in the Forms panel, add a Captcha element (just above Submit is the usual place), and publish.

Do this for every form an anonymous visitor can reach.

4. Check it

  1. Open the page the form is on. Where the Captcha element sits, you should see Cloudflare's Verify you are human box, not the puzzle.
  2. Submit the form. You should get the form's usual confirmation.
  3. In the CMS, View Submissions on the form shows the new entry.

If the form emails anyone, check that the email arrived too (see Troubleshooting if not).

What happens when Cloudflare is down

  • A token Cloudflare refuses (wrong, expired or already used): the submission is refused and the visitor is asked to try the check again.
  • Cloudflare can't be reached: the submission goes through, so your site keeps receiving enquiries during an outage. It's treated as unverified, not as a pass, so it never triggers a visitor confirmation email, and the rate limit and email cap still apply.

The visitor confirmation email

A form can email its confirmation message to the visitor: Submission Settings → Confirmation Messages → Send email. That email goes to an address the visitor typed, which may not be theirs. Left unguarded, anyone could make your site send mail to anyone, from your domain.

So Cendia decides from who sent the form:

Who submitted Confirmation email
A signed-in visitor Sent, with placeholders such as {{Summary}} filled in
An anonymous visitor who passed Turnstile (or another vendor captcha) Sent, with every placeholder removed, so a stranger's inbox never gets text a visitor wrote
An anonymous visitor with no captcha, only the puzzle, or during a Cloudflare outage Not sent. A warning is logged.

No address receives more than 3 confirmations an hour, across every form on the site.

Write the confirmation message so it reads well without placeholders, for example "Thanks, we've received your message and will reply within a day."

During an attack

Settings › Configuration › Form protection holds three controls you can change without a deploy:

Control Default Use it to
Pause form email Off Stop every form sending email at once. Submissions are still stored and webhooks still run.
Per visitor 8 Tighten how often one visitor may post one form in 5 minutes.
Emails per hour 60 Cap the email one form sends in an hour. Past it, submissions are still stored, and the first refusal each hour is logged as an error.

The per-visitor limit can't stop an attacker who rotates through hundreds of addresses; the hourly cap can. A badge on the card shows when email is paused, so it doesn't get left on by mistake.

All three limits count in memory on each server. If the site runs on several servers, each one allows the full amount.

Behind a CDN or proxy (including Cloudflare)

If your site sits behind Cloudflare's proxy (the orange cloud), a load balancer or any other proxy, the site sees the proxy's address rather than the visitor's, unless you tell it which proxy to trust. Without that, the rate limit counts everyone arriving through the same proxy as one visitor: real visitors can block each other, and the CMS login limit can lock out your authors.

For Cloudflare, trust its published address ranges. This goes in appsettings.json, because none of it is secret:

{
  "Cendia": {
    "ForwardedHeaders": {
      "Enabled": true,
      "KnownNetworks": [
        "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
        "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
        "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
        "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
        "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32",
        "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"
      ]
    }
  }
}

Cloudflare publishes these at cloudflare.com/ips and changes them rarely. Check the list when you set this up. Only networks listed here are trusted: a visitor can send an X-Forwarded-For header themselves, so the header is believed only when it arrives from one of these addresses.

For another proxy, list its address in KnownProxies (single addresses) or KnownNetworks (CIDR ranges) instead.

Troubleshooting

What you see Why, and what to do
The form shows the puzzle, not Turnstile Cendia:Forms:Captcha isn't Turnstile in this environment. Set it (step 2) and restart.
An empty space where the captcha should be, and every submission says "This form is not accepting submissions at the moment" Captcha is set to Turnstile, but one of the two keys is missing. Both keys are needed; one alone counts as not configured. The server log names the cause.
Cloudflare's box shows an error instead of Verify you are human This hostname isn't in the widget's hostname list (step 1), or the site key is wrong.
"That did not look right. Please try the check again." on every submission Cloudflare refused the token. The secret key is usually wrong, or belongs to a different widget.
The same message, now and then The visitor waited more than 5 minutes after the check, or reposted an old page. Asking them to try again is the right outcome.
Submissions are stored but no email arrives Open Submission Settings. Each template row shows From → To. A red From means it's on a different domain from your mail setting, and your mail provider is probably refusing it: leave From blank to use the site's sender. Otherwise, check the server log for "the notification email could not be sent", which gives the provider's reason.
The staff email arrives, but the visitor's confirmation doesn't The visitor was anonymous and not verified by a vendor captcha. Check the form has a Captcha element and Turnstile is configured. Also check Visitor email field is set, the address hasn't had 3 confirmations this hour, and form email isn't paused.
Gmail shows "via amazonses.com" (or another provider) next to your address Your mail isn't signed with your own domain. Set up DKIM for the domain with your mail provider.

Writing your own captcha provider

To use a vendor other than Turnstile, you write a small provider. Cendia's core never names a captcha vendor: a package registers one, and Cendia:Forms:Captcha picks it by name, the same way mail and media storage work.

The interface

A provider implements IFormCaptcha from Cendia.Forms:

Member Required What it does
ProviderName Yes The name configuration selects, such as "HCaptcha".
IsConfigured Yes Whether the provider has what it needs to verify. false makes forms with a Captcha element refuse submissions, loudly. Return false unless both keys are present.
SiteKey Yes The public key for the widget, or null.
VerifyAsync(response, remoteIp) Yes Checks the token. true lets the submission through.
CheckAsync(response, remoteIp) No Returns Passed, Failed or Unverified. Implement it so an outage can be Unverified (let through, not vouched for). If you don't, VerifyAsync returning true counts as Passed.
WidgetHtml(responseField) No Markup for the widget, when your provider draws it. Return null to use the generic hook instead.
ScriptUrl No A script the widget needs. Emitted once per page. Used only together with WidgetHtml.

How the token reaches the server

The form posts the captcha's token in a field named __mcCaptcha, and Cendia passes that value to your provider. There are two ways to get it there.

If the vendor's widget can name its response field (Turnstile can, with data-response-field-name), return the widget markup from WidgetHtml and its script from ScriptUrl. The widget then posts straight into __mcCaptcha, and Cendia adds no field of its own. Don't add a hidden __mcCaptcha input yourself: two fields with one name post two values, and verification fails.

If it can't, leave WidgetHtml returning null. The form then renders a generic hook:

<div class="mc-form-captcha" data-mc-captcha data-sitekey="…"></div>
<input type="hidden" name="__mcCaptcha" value="" />

and your site supplies a script that draws the widget into that div and copies the token into the hidden input. The example below takes this route.

Example: hCaptcha

The provider, in your site or your own package:

using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Cendia.Forms;

public sealed class HCaptchaFormCaptcha(
    string? siteKey, string? secretKey, IHttpClientFactory http, ILogger<HCaptchaFormCaptcha> log)
    : IFormCaptcha
{
    public string ProviderName => "HCaptcha";

    // Both keys, or it can never verify: report not-configured, so the CMS refuses loudly.
    public bool IsConfigured => !string.IsNullOrWhiteSpace(siteKey) && !string.IsNullOrWhiteSpace(secretKey);

    public string? SiteKey => IsConfigured ? siteKey : null;

    public async Task<bool> VerifyAsync(string? response, string? remoteIp, CancellationToken ct = default) =>
        await CheckAsync(response, remoteIp, ct) != FormCaptchaOutcome.Failed;

    public async Task<FormCaptchaOutcome> CheckAsync(string? response, string? remoteIp, CancellationToken ct = default)
    {
        if (!IsConfigured || string.IsNullOrWhiteSpace(response)) return FormCaptchaOutcome.Failed;

        var fields = new Dictionary<string, string> { ["secret"] = secretKey!, ["response"] = response };
        if (!string.IsNullOrWhiteSpace(remoteIp)) fields["remoteip"] = remoteIp;

        try
        {
            using var posted = new FormUrlEncodedContent(fields);
            using var reply = await http.CreateClient("hcaptcha")
                .PostAsync("https://api.hcaptcha.com/siteverify", posted, ct);
            reply.EnsureSuccessStatusCode();

            var result = await reply.Content.ReadFromJsonAsync<Result>(ct);
            return result?.Success == true ? FormCaptchaOutcome.Passed : FormCaptchaOutcome.Failed;
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            // The vendor is unreachable: let the submission through, but never as a pass.
            log.LogError(ex, "hCaptcha could not be reached; the submission goes through unverified.");
            return FormCaptchaOutcome.Unverified;
        }
    }

    private sealed class Result
    {
        [JsonPropertyName("success")] public bool Success { get; set; }
    }
}

Register it in Program.cs, after AddCendia:

using Cendia.Forms;

builder.Services.AddHttpClient("hcaptcha", c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(new FormCaptchaProvider("HCaptcha", sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new HCaptchaFormCaptcha(
        config["Cendia:Forms:HCaptcha:SiteKey"],
        config["Cendia:Forms:HCaptcha:SecretKey"],
        sp.GetRequiredService<IHttpClientFactory>(),
        sp.GetRequiredService<ILogger<HCaptchaFormCaptcha>>());
}));

Select it with Cendia__Forms__Captcha=HCaptcha, and set Cendia__Forms__HCaptcha__SiteKey and Cendia__Forms__HCaptcha__SecretKey the same way as for Turnstile.

Then add this script to your site's layout. It draws a widget into every generic hook on the page and copies each token into its own form's hidden field, so it also works with several forms on one page:

<script>
  window.cendiaHCaptchaReady = function () {
    document.querySelectorAll('[data-mc-captcha]').forEach(function (hook) {
      var field = hook.closest('form').querySelector('input[name="__mcCaptcha"]');
      hcaptcha.render(hook, {
        sitekey: hook.dataset.sitekey,
        callback: function (token) { field.value = token; },
        'expired-callback': function () { field.value = ''; },
        'error-callback': function () { field.value = ''; }
      });
    });
  };
</script>
<script src="https://js.hcaptcha.com/1/api.js?render=explicit&onload=cendiaHCaptchaReady" async defer></script>

Rules for any provider

  • Refuse on a bad token. Only return Passed (or true) when the vendor said yes.
  • An outage is Unverified, not Passed. A pass is what unlocks the visitor confirmation email.
  • Both keys or not configured. A site key alone draws a widget that can never verify.
  • HTML-encode anything you put into WidgetHtml, including the site key.
  • Never log or store the token or the visitor's IP address. Pass the IP to the vendor and no further.

Custom markup and headless front ends

If you replace the form's markup (your own Pages/Shared/Blocks/Form.cshtml) or post to the form from your own front end, keep to the contract:

  • Post the captcha token as __mcCaptcha, to /forms/{formId}/submit.
  • The honeypot (__mcWebsite) and the fill-time stamp (__mcRendered) are optional. If a field isn't posted, its check is skipped, so a front end that never sends them keeps working. If you do post them, leave the honeypot empty, and make the stamp the time the form was shown, in Unix milliseconds.
  • A post with Accept: application/json gets JSON back instead of a redirect. A refusal is { "success": false, "errors": { … } } with a 4xx status.

Configuration reference

{
  "Cendia": {
    "Forms": {
      "Captcha": "Turnstile",
      "Turnstile": {
        "SiteKey": "0x4AAAAAAA…"
      }
    }
  }
}
Setting Meaning
Cendia:Forms:Captcha Which provider to use. Unset means the puzzle. Puzzle names the puzzle explicitly. None refuses every submission on a form with a Captcha element. A name nobody registered behaves like None, rather than quietly falling back to the puzzle.
Cendia:Forms:Turnstile:SiteKey Turnstile's public site key. Safe in appsettings.json.
Cendia:Forms:Turnstile:SecretKey Turnstile's secret key. User-secrets or an environment variable only.

The limits in During an attack are set in the CMS, not in configuration.