SpamGuard protects ASP.NET Core forms from spam by combining several checks: honeypot fields, a signed timing token, Google reCAPTCHA v3, gibberish detection, keyword and email heuristics, per-IP rate limiting, and an optional StopForumSpam lookup. Each rule produces a signal, and the signals are combined into one confidence score.
- Honeypot field. Catches bots that fill in every input.
- Signed timing token. The form's render time is protected with ASP.NET Core Data Protection, so a client cannot forge it.
- Google reCAPTCHA v3. Checks the score, and optionally the expected action and allowed hostnames.
- Random text detection. Flags gibberish such as
MruwAXBlxLlfnUjjZooNVL. It is Unicode-aware and protected against ReDoS. - Keyword blacklist. Matches whole words only, case-insensitively.
- Suspicious email patterns. Flags disposable domains,
+spamtags and dotted local parts. - Thread-safe per-IP rate limiting.
- StopForumSpam lookup (opt-in). Checks the client IP and an MD5 hash of the email, with caching. If the service is unavailable, the check passes.
<spam-guard-fields />tag helper andHttpContexthelpers. Wire up a form in a few lines.- Extensible. Add your own rules, or disable any built-in rule by name.
- Startup validation. Configuration errors are reported when the app starts, not on the first submission.
dotnet add package Our.AspNetCore.SpamGuardRequires .NET 10.
using AspNetCore.SpamGuard;
builder.Services
.AddSpamGuard(builder.Configuration.GetSection("SpamGuard")) // or .AddSpamGuard() to bind "SpamGuard" automatically
.AddSpamRule<MyCustomRule>(); // optionalThere are three overloads:
| Overload | Behavior |
|---|---|
AddSpamGuard() |
Binds the SpamGuard configuration section. |
AddSpamGuard(IConfiguration section, Action<SpamGuardOptions>? configure = null) |
Binds the section you pass, then applies the optional delegate. |
AddSpamGuard(Action<SpamGuardOptions> configure) |
Code-only configuration; nothing is bound from configuration. |
Calling AddSpamGuard more than once is safe; the built-in rules are not registered twice.
In _ViewImports.cshtml:
@addTagHelper *, AspNetCore.SpamGuardIn the form:
<form method="post" asp-action="Contact">
<input name="name" />
<input name="email" type="email" />
<textarea name="message"></textarea>
<!-- Renders an off-screen honeypot and a signed render-time token -->
<spam-guard-fields />
<button type="submit">Send</button>
</form>The tag helper accepts these attributes:
| Attribute | Default | Description |
|---|---|---|
include-honeypot |
true |
Renders the honeypot input (HoneypotFieldName). |
include-form-token |
true |
Renders the signed timestamp (FormTokenFieldName). |
include-recaptcha |
false |
Renders an empty hidden input (RecaptchaTokenFieldName) for your reCAPTCHA script to fill. |
honeypot-label |
Leave this field empty |
Label text read by screen readers. |
Classic form post:
using AspNetCore.SpamGuard;
using AspNetCore.SpamGuard.Abstractions;
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Contact([FromServices] ISpamGuard spamGuard)
{
var context = await HttpContext.CreateSpamCheckContextAsync(HttpContext.RequestAborted);
var result = await spamGuard.CheckAsync(context, HttpContext.RequestAborted);
if (result.IsSpam)
{
_logger.LogWarning("Spam blocked ({Score:F2}): {Rules}", result.ConfidenceScore, string.Join(", ", result.TriggeredRules));
}
// Show the same response either way, so bots cannot learn which submissions were blocked.
return RedirectToAction("ThankYou");
}CreateSpamCheckContextAsync reads every form field into FormData. It reads the honeypot, reCAPTCHA token, form token and formRenderedAt fields using their configured names, and ignores __RequestVerificationToken. It also fills in the client IP, user agent and submission time.
JSON API: post the hidden __SpamGuardToken value along with your model, then:
var context = HttpContext.CreateSpamCheckContext(
new Dictionary<string, string?>
{
["name"] = model.Name,
["email"] = model.Email,
["message"] = model.Message
},
recaptchaToken: model.RecaptchaToken,
honeypotValue: model.Website,
formToken: model.FormToken);
var result = await spamGuard.CheckAsync(context, HttpContext.RequestAborted);You can still build SpamCheckContext by hand. FormData keys are matched case-insensitively.
Every rule that triggers adds its Severity (a value from 0 to 1) to a combined score using noisy-OR:
score = 1 - (1 - s1) * (1 - s2) * ... * (1 - sn)
The submission is spam when score >= SpamConfidenceThreshold (0.7 by default).
- The score never goes above 1.
- Each extra signal raises the score.
- With the default threshold:
- These rules flag a submission on their own: Honeypot, SpamDatabase, RandomText, Recaptcha, RateLimit and Timing.
- These rules need another signal before the submission is flagged: KeywordBlacklist and SuspiciousEmail. For example, two 0.6 signals give a score of 0.84.
SpamCheckResult contains:
| Property | Contents |
|---|---|
IsSpam |
Whether the score reached the threshold. |
ConfidenceScore |
The combined score. |
TriggeredRules |
Names of the rules that triggered. |
FailedRules |
Rules that threw an exception. These count as not triggered and are logged. |
Messages |
Human-readable notes. |
Rule (SpamRuleNames) |
Severity | Triggers when |
|---|---|---|
Honeypot |
0.95 | The honeypot field has a value. |
SpamDatabase |
0.90 | Opt-in. StopForumSpam lists the client IP or email hash with confidence ≥ SpamDatabase.MinConfidence. |
RandomText |
0.85 | Checked fields look like gibberish. Latin-only heuristics are skipped for non-Latin scripts. |
Recaptcha |
0.80 | Verification fails, the score is below RecaptchaMinScore, or the action or hostname doesn't match. |
RateLimit |
0.75 | More than RateLimitMaxSubmissions from one IP within the window. |
Timing |
0.70 | Submitted faster than MinFormFillTimeSeconds, or the form token is invalid. |
KeywordBlacklist |
0.65 | A configured keyword appears as a whole word. |
SuspiciousEmail |
0.60 | Disposable domain, +spam@, 4 or more dot-separated local-part segments, or longer than 320 characters. |
To disable a rule without removing it:
"SpamGuard": { "DisabledRules": [ "KeywordBlacklist" ] }- If
RecaptchaSecretKeyis not set, the rule is skipped. This is allowed only whenRequireRecaptchaisfalse; otherwise startup fails. - If the token is missing, the submission counts as spam only when
RequireRecaptchaistrue. - Set
RecaptchaExpectedAction(for example"submit") andRecaptchaAllowedHostnamesto stop tokens issued for another page or site from being reused. RecaptchaSiteKeyis provided so your views can read it from options instead of hard-coding it.- Store the secret in user-secrets, environment variables or Key Vault, never in
appsettings.json.
<spam-guard-fields /> (or ISpamGuardFormTokenService.GenerateToken()) issues a token containing the server time when the form was rendered, protected with Data Protection.
- An invalid or tampered token counts as spam.
- Set
RequireFormToken: trueto also treat a missing token as spam. This is recommended once all your forms render the token. - Without a token, the rule falls back to the client-supplied
FormRenderedAtvalue, which a client can forge. FlagStaleSubmissions: truealso flags forms submitted later thanMaxFormFillTimeSecondsafter rendering.
Multiple servers or containers: Data Protection keys must be shared, for example with
PersistKeysToAzureBlobStorage,PersistKeysToStackExchangeRedisor a shared file system. Otherwise a token issued by one instance fails validation on another, and that submission is flagged.
The rate limiter keeps per-client counts in IMemoryCache, so each app instance counts separately. IPv4 clients are counted per address, and IPv6 clients per /64 prefix, because one host usually controls a whole /64. Behind a reverse proxy or load balancer, configure forwarded headers so SpamGuard sees the real client IP:
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// o.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
});
app.UseForwardedHeaders();This check is disabled by default because it sends data to a third party:
- the client IP, but only if it is public (private, loopback and link-local addresses are never sent);
- the MD5 hash of the lowercased email (never the raw address).
"SpamGuard": {
"SpamDatabase": {
"Enabled": true,
"MinConfidence": 50,
"CheckIp": true,
"CheckEmail": true,
"CacheMinutes": 60,
"TimeoutSeconds": 3
}
}If the service is slow or unavailable, the check passes (it fails open). Mention this lookup in your privacy policy.
{
"SpamGuard": {
"SpamConfidenceThreshold": 0.7,
"RecaptchaSecretKey": "",
"RecaptchaSiteKey": "",
"RecaptchaMinScore": 0.5,
"RequireRecaptcha": true,
"RecaptchaExpectedAction": null,
"RecaptchaAllowedHostnames": null,
"MinFormFillTimeSeconds": 2,
"MaxFormFillTimeSeconds": 3600,
"FlagStaleSubmissions": false,
"RequireFormToken": false,
"RandomTextCheckFields": null,
"RandomTextThreshold": 1,
"RandomTextMaxInputLength": 2000,
"EnableRateLimit": true,
"RateLimitWindowMinutes": 10,
"RateLimitMaxSubmissions": 3,
"SpamKeywords": null,
"DisabledRules": null,
"HoneypotFieldName": "website",
"FormTokenFieldName": "__SpamGuardToken",
"RecaptchaTokenFieldName": "recaptchaToken",
"FormRenderedAtFieldName": "formRenderedAt",
"EmailFieldName": "email",
"SpamDatabase": { "Enabled": false }
}
}Notes on the defaults:
- When
RandomTextCheckFieldsis null, the rule checksname,message,subjectandcomment. - Lists default to
null, so configured values replace the defaults instead of being appended to them. RequireRecaptchadefaults totrue, so you must either setRecaptchaSecretKeyor setRequireRecaptchatofalse. Invalid settings throw anOptionsValidationExceptionat startup.
public sealed class ExcessiveCapsRule : ISpamRule
{
public string RuleName => "ExcessiveCaps";
public double Severity => 0.4;
public Task<bool> IsSpamAsync(SpamCheckContext context, CancellationToken cancellationToken = default)
{
if (!context.FormData.TryGetValue("message", out var message) || message.Length < 10)
return Task.FromResult(false);
var letters = message.Where(char.IsLetter).ToArray();
return Task.FromResult(letters.Length >= 10 && letters.Count(char.IsUpper) > letters.Length * 0.7);
}
}
builder.Services.AddSpamGuard().AddSpamRule<ExcessiveCapsRule>();Rules are scoped services, so they can inject DbContext, HttpClient (via IHttpClientFactory) and so on. A rule that throws is reported in FailedRules and does not count toward the score.
- Don't reveal detection. Return the same response for spam and legitimate submissions.
- Log scores, not PII. Log the triggered rules and score, then tune
SpamConfidenceThresholdand severities from real data. - Enable
RequireFormTokenonce every form renders<spam-guard-fields />. - Keep secrets out of source control. Use user-secrets locally and a secret store in production.
Version 2.0 contains breaking changes:
- Scoring is noisy-OR. 1.x averaged the triggered severities, so adding a weak signal could lower the score; for example, Honeypot (0.95) plus SuspiciousEmail (0.6) averaged to 0.775. Re-check
SpamConfidenceThresholdif you tuned it. AddSpamGuard(...)returnsISpamGuardBuilderinstead ofIServiceCollection..AddSpamRule<T>()still chains.- Other
IServiceCollectioncalls needbuilder.Services. - New overloads bind configuration directly.
- Options are validated at startup.
RequireRecaptcha = true(the default) with noRecaptchaSecretKeynow fails at startup. Previously the reCAPTCHA check threw on every request, and the error was swallowed, so reCAPTCHA was silently skipped. SpamDatabaseRuleis now a real StopForumSpam integration and is disabled by default. The 1.x rule called a placeholder URL (api.spamcheck.example) on every submission.- List options default to
null.- Configured arrays now replace the built-in defaults instead of being appended to them.
SpamKeywordsno longer has built-in words.
- Keyword matching uses whole words ("sex" no longer matches "Essex").
SuspiciousEmailRuleno longer flags numeric local parts such as12345@gmail.com.- Rule exceptions are listed in
SpamCheckResult.FailedRules, and exception messages are no longer returned inMessages. ISpamGuardandISpamRulelive inAspNetCore.SpamGuard.Abstractions.- The package uses the shared framework (
Microsoft.AspNetCore.App) instead of the deprecatedMicrosoft.AspNetCore.Http.Abstractions2.x package.
AspNetCore.SpamGuard.Web is a sample app with a JSON contact form at /Home/Contact. It runs without reCAPTCHA by default. In Development, the API response includes diagnostics showing the score and the triggered rules.
- Fork the repository and create a feature branch.
- Add tests in
AspNetCore.SpamGuard.Testsand rundotnet test. - Open a pull request.
MIT