Protecting public forms in ASP.NET Core without annoying real people
Every public form attracts bots eventually: contact forms, quote requests and newsletter signups. A single CAPTCHA helps, but it isn't enough on its own. CAPTCHAs score one request; they don't limit how many requests arrive, and they don't stop someone using your signup form to flood a stranger's inbox.
Here's the layered approach we use in ASP.NET Core. Each layer is cheap, and together they stop almost everything without annoying real people.
1. A honeypot field
Add a field that humans never see and bots happily fill in:
<div class="hp-field" aria-hidden="true">
<label>Leave this empty
<input type="text" name="Website" tabindex="-1" autocomplete="off" />
</label>
</div>
.hp-field { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
If Website comes back with a value, it's a bot. Don't show an error. Redirect to the normal "thanks" page and store nothing, so the bot thinks it succeeded and moves on.
2. A signed "time to fill" stamp
People take more than a few seconds to fill in a form; scripts don't. Put the render time in a hidden field, signed with ASP.NET Core Data Protection so it can't be forged:
var protector = dataProtectionProvider.CreateProtector("FormGuard.v1");
// When rendering the form
string stamp = protector.Protect(DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
// When the form is posted
var rendered = new DateTime(long.Parse(protector.Unprotect(stampFromForm)), DateTimeKind.Utc);
var age = DateTime.UtcNow - rendered;
bool tooFast = age < TimeSpan.FromSeconds(3); // bot: pretend success
bool tooOld = age > TimeSpan.FromHours(4); // stale or replayed: ask them to resubmit
If the stamp is missing or tampered with, Unprotect throws a CryptographicException; treat that as an expired form. Persist your Data Protection keys (for example PersistKeysToFileSystem) so an app restart doesn't invalidate every open form.
3. Cloudflare Turnstile
Turnstile is Cloudflare's free CAPTCHA alternative. Most visitors never see a puzzle; it only asks for a click when something looks off. The browser widget adds a cf-turnstile-response field to your form, and the server verifies it:
var form = new Dictionary<string, string>
{
["secret"] = secretKey,
["response"] = token,
["remoteip"] = clientIp
};
using var resp = await http.PostAsync(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
new FormUrlEncodedContent(form));
var result = await resp.Content.ReadFromJsonAsync<SiteVerifyResponse>();
bool human = result is { Success: true };
Two practical tips:
- Fail closed. If the verification call throws, treat the submission as unverified.
- Use the test keys locally. Real Turnstile keys don't allow
localhost. Cloudflare publishes always-pass and always-fail test keys, so you can exercise both paths in development.
4. Rate limiting per IP
ASP.NET Core has rate limiting built in. A fixed window per client IP is plenty for public forms:
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddPolicy("forms", ctx => RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(10),
QueueLimit = 0
}));
});
app.UseRateLimiter();
[HttpPost("/contact")]
[EnableRateLimiting("forms")]
public async Task<IActionResult> Contact(ContactFormModel model) { ... }
Make the limits configurable, relax them in appsettings.Development.json so you can test freely, and return a friendly page (not a bare 429) when someone hits the limit. If your site sits behind Cloudflare's proxy, partition on the CF-Connecting-IP header instead, but only when you know the request really came through Cloudflare.
5. Protect people, not just your database
For newsletter signups, two more rules matter:
- Double opt-in. Nobody is subscribed until they click the confirmation link, so a bot can't put real people on your list.
- Per-address cooldown and a daily cap. Don't resend a confirmation to the same address within, say, 15 minutes, and cap confirmation emails per day. Otherwise your form becomes a free tool for harassing someone's inbox, and your email provider's quota disappears with it.
Also keep the response identical whether an address is new, pending or already subscribed. That way the form can't be used to discover who's on your list.
Putting it together
Run the checks in order from cheapest to most expensive: honeypot, then timing, then Turnstile, with rate limiting applied before any of it. Bots get a quiet fake success; real people who fail a check get a clear message and can try again.
This exact setup protects the forms on this site and ships with the upcoming TrussCode Starter Kit.
Get new articles by email
Practical .NET and SQL Server notes, a couple of times a month.
Keep reading
Hello from TrussCode
TrussCode is open: custom software, engineering, custom 3D printed parts and a supply chain network - with one point of contact from idea to delivery.
From idea to 3D printed part: how a custom part gets made
Photo, sketch or CAD file - how a custom plastic part goes from idea to delivery: what to send, choosing a material, prototyping and moving to production.
Fast search pages in .NET: stored procedures, Dapper and DataTables
The simple pattern we use for admin search screens on SQL Server: a parameterized stored procedure, Dapper to call it and jQuery DataTables to present it - and what to change when results get big.