In part two we filtered each submission individually: lengths, encoding, the signed timestamp token, and the challenge verified on the server. However, the attack that started the series showed that a form does not fall with a single submission, but with volume: 41 IPs, one message every few minutes, for three days. This part deals with the layers that concern the whole: how much someone sends, what it contains, how the email is sent, and who notices when something is wrong.
The Applied Security series for web forms:
- Part 1: what the attacker wants and form design
- Part 2: validation, signed tokens, and challenge
- Part 3: rate limiting, scoring, email, and monitoring (this article)
- Part 4: conclusions and checklist
Layer 5: rate limiting on multiple keys
The lesson from the incident was not that rate limiting doesn't work, but that a single key (the IP) is not enough. Limiting on multiple keys covers different scenarios:
- Per IP: stops the attacker with a single address and the user who presses send ten times.
- Global, per endpoint: a cap for the total submissions, regardless of how many IPs the attacker has. This is the layer that would have limited the damage for us, even with 41 addresses.
- Per recipient or per entered address: the same email address does not need to appear 20 times in one day. This is checked in the handler, based on saved data.
The rate limiting integrated in ASP.NET Core (from .NET 7) allows a policy per endpoint plus a global limiter. Its fundamentals were covered in the article about circuit breaker and bulkhead. Here we combine them:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Per IP: 3 submissions per hour, sliding window
options.AddPolicy("contact-per-ip", context =>
RateLimitPartition.GetSlidingWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromHours(1),
SegmentsPerWindow = 6,
QueueLimit = 0
}));
// Global: total cap on the form, regardless of how many IPs the attacker has
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
context.Request.Path.StartsWithSegments("/api/contact")
? RateLimitPartition.GetFixedWindowLimiter("contact-global", _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromHours(1),
QueueLimit = 0
})
: RateLimitPartition.GetNoLimiter("other"));
});
app.UseForwardedHeaders(); // before rate limiter, see below
app.UseRateLimiter();
app.MapPost("/api/contact", HandleContactAsync)
.RequireRateLimiting("contact-per-ip");
The global cap has a known cost: an attacker can consume it and block the form for real users until the next hour. It is an explicit, and good, compromise: a form unavailable for an hour is much cheaper than 109 phishing emails sent from your domain. Choose the cap based on your real volume (how often have you ever received 30 legitimate messages in one hour?).
Two traps that nullify everything
- The wrong IP behind a proxy. On Azure App Service, behind Front Door, Cloudflare, or a load balancer,
RemoteIpAddressis the proxy's address, so all visitors share the same limit. The solution isUseForwardedHeaders, configured to acceptX-Forwarded-Foronly from your known proxies. If you accept the header from anyone, the attacker chooses their own IP. - In-memory state. The built-in limiter keeps counters in process memory: they reset on each restart and are not shared between instances. For us, this exactly made the limit even weaker. If you run on multiple instances, move the limiting to a distributed store (for example Redis) or at the network edge, with the rate limiting rules of the WAF in Front Door or Cloudflare.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.4")); // your proxy, not anyone
});
Layer 6: content scoring
When everything above has passed, the content remains. The concrete rules we built after the incident (link in the name field, very long names, Cyrillic alphabet with small weight, random letter and digit tokens) are described in the postmortem. Here the structure matters, which can be reused:
public sealed record ScoreRule(string Name, int Weight, Func<ContactForm, bool> Matches);
public sealed class SubmissionScorer(IEnumerable<ScoreRule> rules, ILogger<SubmissionScorer> logger)
{
public const int Threshold = 5;
public (int Score, string[] Hits) Evaluate(ContactForm form)
{
var hits = rules.Where(r => r.Matches(form)).ToArray();
var score = hits.Sum(r => r.Weight);
var names = hits.Select(h => h.Name).ToArray();
logger.LogInformation("Contact form score {Score} rules {Rules}", score, names);
return (score, names);
}
}
// Example rules: a single rule blocks only when it is a very strong signal
static readonly Regex LinkPattern = new(@"https?://|www\.|\.(ru|io|xyz)\b", RegexOptions.IgnoreCase);
ScoreRule[] rules =
[
new("link-in-name", 10, f => LinkPattern.IsMatch(f.Name)),
new("long-name", 3, f => f.Name.Length > 70),
new("cyrillic-name", 2, f => f.Name.Any(c => c is >= 'Ѐ' and <= 'ӿ')),
];
- Cumulative score, not binary rules. A visitor from Bulgaria or Ukraine legitimately writes with Cyrillic letters. That rule has a small weight and only matters together with other signals.
- Keep a corpus. Save submissions, including blocked ones, and test any new rule on the corpus before deploying. This way we could say, before publishing, that the new filter blocks 108 of the 109 attack messages.
- Do not tell the bot why it was rejected. Messages blocked based on the score receive the same response as accepted ones. An attacker who sees the specific error adjusts the content until it passes. Blocked messages remain saved to recover possible false positives.
Layer 7: isolate email sending
A detail from the incident is worth repeating: the phishing emails had valid SPF and DKIM. Email authentication proves who sent the message, not that the message is honest. It protects you from those pretending to be your domain, not from abuse of your own form. This does not make it optional, on the contrary.
SPF, DKIM, and DMARC are no longer optional
- Gmail requires, from February 2024, aligned SPF, DKIM, and DMARC for senders who send around 5,000 messages per day to personal Gmail accounts. From November 2025 it applies the rules more strictly: non-compliant messages are first delayed and may then be permanently rejected.
- Outlook.com rejects, from May 5, 2025, non-compliant messages from senders with over 5,000 emails per day, with error
550 5.7.15. - Even if you are below the threshold, correct configuration keeps you in the inbox and prepares you for the day an incident pushes you over the threshold.
DMARC is introduced gradually: you start with p=none and aggregate reports, read the reports for several weeks to discover all services sending on your behalf, then move to quarantine and finally to reject.
notificari.exemplu.ro. TXT v=spf1 include:spf.furnizor-email.example -all
_dmarc.exemplu.ro. TXT v=DMARC1; p=none; rua=mailto:dmarc-rapoarte@exemplu.ro
A subdomain and a dedicated provider for the application's emails
In our incident, the restriction applied by Microsoft 365 blocked all sent emails, including internal correspondence. The cause: the form was sending through the same Microsoft 365 mailbox used by the team. Separation is simple:
- Emails generated by the application are sent from a subdomain (for example
notificari.exemplu.ro), with their own SPF and DKIM records. - They are sent by a transactional service (Azure Communication Services Email or another dedicated provider), not the company's mailbox.
- Abuse of the form thus affects the application's flow, not the email people work with.
An important nuance: separation by subdomain is not a complete reputation wall. Gmail, for example, aggregates subdomain volume to the main domain when determining if you are a mass sender. Separation limits the effects of an incident but does not replace the layers above.
Layer 8: monitoring and a kill switch
On October 7, the first signal of the attack did not come from us, but from Microsoft 365, which restricted the account after three days of automated submissions, about 10 per hour. A public form must tell you itself that something is wrong, hours or days before the email provider.
- A structured log for each submission: the result (accepted, blocked by score, rejected by challenge, limited), the score, triggered rules. Without the full message text in the log and with a clear retention period, because it involves personal data.
- A metric and an alert: a counter with a result tag, with low cardinality, and an alert when volume exceeds several times the usual average. How to build them, in the article about custom metrics and dashboards.
- A kill switch: a feature flag that stops sending emails but continues saving messages. During an incident you want to stop the damage in seconds, without deployment, and without losing real messages.
What’s next
We have gone through all eight layers. In the final part we put them together: the complete handler where each layer has its place and order, a checklist applicable to any public form, and the mistakes that practically nullify all the protections above.