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.comandwww.example.comif 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
- 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.
- Submit the form. You should get the form's usual confirmation.
- 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(ortrue) when the vendor said yes. - An outage is
Unverified, notPassed. 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/jsongets 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.