Security at the Edge: WAF, DDoS Protection, Rate Limiting, Bot Management and Locking Down the Origin
The WAF, DDoS protection, rate limits and bot rules at the edge only see traffic that goes through the edge; if the origin's IP leaks they are all bypassed, so the origin must accept connections only from the CDN. On an Nginx edge we see a login rate limit stop an attack before it reaches the origin, how forwarded header order in the ASP.NET Core rate limiter protects customers, and why an IP allowlist must be completed with Authenticated Origin Pulls, a tunnel or X-Azure-FDID.
Version note: The examples in this post were run with .NET 10 (the ASP.NET Core rate limiter), xUnit 2.9 and Microsoft.AspNetCore.Mvc.Testing 10.0.15, and Nginx 1.30.6 as the edge on Docker 29; Cloudflare and Azure Front Door features and plan limits are taken from the official documentation as of 14 November 2026 and were not run in a Cloudflare or Azure account.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, is behind Cloudflare: the WAF is on, there is a rate limiting rule for /api/auth/login, and Bot Fight Mode is on. One night thousands of attempts per minute hit the login endpoint, and none of them show up in Cloudflare's panel. The attacker found Bookstore's A record from before the move to Cloudflare in a historical DNS database and is connecting to the origin directly.
Guess: which of the rules at the edge stops this attacker? Does the ASP.NET Core rate limiter on the origin stop them, and if so, what do real customers experience meanwhile? This post looks at the security layers at the edge from the defender's side and ends with the answer to that question: we cover detection and prevention, not attack techniques.
Layers: What Stops Where?
Edge security is not a single product but layers that run in order. Roughly, the order a request meets them in:
| Layer | Against | In Bookstore |
|---|---|---|
| Network-level DDoS (L3/L4) | Traffic that fills bandwidth or connection tables | Cloudflare's anycast network, on all plans |
| HTTP DDoS (L7) | Valid-looking but excessive HTTP requests | Cloudflare's HTTP DDoS managed ruleset |
| WAF | Known attack signatures, application-specific rules | Managed rulesets and custom rules |
| Rate limiting | Too many requests from one source | /api/auth/login and the order endpoints |
| Bot management | Automated tools | Bot Fight Mode, challenges where needed |
| Origin lockdown | Everything that bypasses the edge | Only connections from Cloudflare |
The last row is the prerequisite for all the others. Every rule at the edge assumes that traffic goes through the edge. That is also the answer to Guess First: no rule at the edge can see a request that never visits the edge. We look at the other layers first and leave origin lockdown for last, because that is where the main lesson of this post is.
The WAF: Managed Rulesets, Custom Rules and Observing First
The WAF is the layer that inspects requests against rules and blocks or challenges them. Cloudflare has two kinds of rules. Managed rulesets are rule sets Cloudflare writes and updates: known CVEs, SQL injection and XSS signatures. Custom rules are rules specific to your application, such as "the admin panel only from the company's IPs" or "POST to /api/ only with application/json".
Plan limits change often; this is what Cloudflare's documentation says today: the Free Managed Ruleset is on for all plans. The Cloudflare Managed Ruleset and the OWASP Core Ruleset (Cloudflare's implementation of the OWASP rule set) are on Pro and above. Exposed Credentials Check is marked deprecated, with leaked credentials detection recommended instead. Since Bookstore hosts user login, the Pro plan's rulesets, especially the OWASP Core Ruleset, are worth the price. The application-side counterparts of the OWASP Top 10 are in the Web Security (OWASP Top 10) series; the WAF doesn't fix those vulnerabilities, it only makes exploiting them harder.
Turning a new rule on straight away with "Block" means discovering false positives (legitimate requests blocked by mistake) in production. The right order is: observe first, then block. On Cloudflare, the "Log" action that runs a rule in record-only mode is available only on Enterprise plans. On other plans two options remain. The first is to turn the rule on with a narrow scope (a single path or a test host) and watch the Security Events screen. The second is to start with Managed Challenge instead of Block: a legitimate user passes the challenge, a bot doesn't; the events still show up in Security Events. In Bookstore the most likely sources of false positives are known: HTML entered as book descriptions in the admin panel and CSV files uploaded in the institutional portal. For these, rather than turning the rule off entirely, write a skip/exception for that path and note the reason in the rule's description.
Rate Limiting: Edge and Origin, Layer by Layer
A password-guessing attack on a login endpoint has two faces: high volume from one source, and low volume from many sources. The edge stops the first cheaply; the second needs the application's own rules. Cloudflare's rate limiting rules differ a lot by plan: per the documentation, the Free plan has 1 rule, counting by IP only, a 10-second counting period and a 10-second block; Pro has 2 rules and periods up to 1 minute; Business has 5 rules and counting that can separate users behind NAT in addition to IP; counting by header, cookie or JSON field is on Enterprise. Bookstore's single rule is for login: block if more than 5 POST /api/auth/login requests arrive from the same IP within 10 seconds.
I reproduced the edge behaviour in the lab with Nginx's limit_req. This time the origin runs on HTTPS in Kestrel with a self-signed certificate; the edge connects to it over HTTPS, matching Cloudflare's Full mode:
# edge-side rate limit for the login endpoint (stands in for a CDN rate limiting rule)
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
upstream origin_tls {
server host.docker.internal:5443;
}
server {
listen 8082;
server_name bookstore.example;
proxy_set_header Host $host;
location = /api/auth/login {
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
proxy_pass https://origin_tls;
}
location / {
proxy_pass https://origin_tls;
}
}
$ for i in $(seq 1 8); do curl -s -o /dev/null -D /tmp/e.hdr -X POST -H "Host: bookstore.example" \
-H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"wrong"}' \
http://127.0.0.1:8082/api/auth/login; echo "$i: $(head -1 /tmp/e.hdr | tr -d '\r') $(grep -i '^x-origin-request' /tmp/e.hdr | tr -d '\r')"; done
1: HTTP/1.1 401 Unauthorized X-Origin-Request: 2
2: HTTP/1.1 401 Unauthorized X-Origin-Request: 3
3: HTTP/1.1 401 Unauthorized X-Origin-Request: 4
4: HTTP/1.1 401 Unauthorized X-Origin-Request: 5
5: HTTP/1.1 401 Unauthorized X-Origin-Request: 6
6: HTTP/1.1 401 Unauthorized X-Origin-Request: 7
7: HTTP/1.1 429 Too Many Requests
8: HTTP/1.1 429 Too Many Requests
The first six requests (one allowance plus a burst of five) reached the origin and got 401 for the wrong password. Requests seven and eight have no X-Origin-Request: the edge returned the 429, and the origin never saw them. That is the real value of an edge limit. The cost of the attack stays at the edge instead of landing on the origin's CPU, the database and password hashing.
The second layer on the origin is ASP.NET Core's rate limiter. We covered the algorithms, choosing a partition key and "why a per-IP limit is not enough" in the ASP.NET Core Security series post on rate limiting. Here we look at one detail that comes with being behind a CDN:
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
namespace Bookstore.Api;
public static class EdgeProtection
{
public const string LoginPolicy = "login";
// Second layer behind the CDN's rate limit: per client IP, after forwarded headers are applied.
public static IServiceCollection AddLoginRateLimit(this IServiceCollection services) =>
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(LoginPolicy, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1) }));
});
// Azure Front Door: reject requests that did not come through our own profile.
public static IApplicationBuilder RequireFrontDoorId(this IApplicationBuilder app, string? frontDoorId) =>
frontDoorId is null
? app
: app.Use(async (context, next) =>
{
if (context.Request.Headers["X-Azure-FDID"] != frontDoorId)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
await next();
});
}
builder.Services.AddLoginRateLimit();
var app = builder.Build();
app.UseForwardedHeaders();
app.RequireFrontDoorId(app.Configuration["FrontDoor:Id"]);
app.UseHttpsRedirection();
app.UseRateLimiter();
// ...
api.MapPost("/auth/login", (LoginRequest login) =>
// lab only: a real implementation checks the password with ASP.NET Core Identity
login.Password == "correct horse" ? Results.Ok() : Results.Unauthorized())
.RequireRateLimiting(EdgeProtection.LoginPolicy);
The partition key is Connection.RemoteIpAddress. Middleware order is critical here: UseForwardedHeaders must run before UseRateLimiter. Otherwise all users behind Cloudflare share a handful of Cloudflare edge IPs, and a single attacker uses up the login allowance of everyone coming through that edge. I verified this with a test in which every request comes from the same Cloudflare edge:
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
namespace Bookstore.Api.Tests;
public class EdgeProtectionTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private static readonly byte[] WrongPassword = Encoding.UTF8.GetBytes("""{"email":"ada@example.com","password":"wrong"}""");
private static Task<HttpContext> Login(TestServer server, string clientIp) =>
server.SendAsync(c =>
{
c.Request.Method = "POST";
c.Request.Scheme = "http";
c.Request.Host = new HostString("bookstore.example");
c.Request.Path = "/api/auth/login";
c.Request.ContentType = "application/json";
c.Request.ContentLength = WrongPassword.Length;
c.Request.Body = new MemoryStream(WrongPassword);
c.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestHasBody());
c.Connection.RemoteIpAddress = IPAddress.Parse("173.245.48.10"); // every request arrives from the same Cloudflare edge
c.Request.Headers["CF-Connecting-IP"] = clientIp;
c.Request.Headers["X-Forwarded-Proto"] = "https";
});
[Fact]
public async Task The_login_limit_is_per_visitor_not_per_edge()
{
var server = factory.Server;
var attacker = new List<int>();
for (var i = 0; i < 11; i++)
{
attacker.Add((await Login(server, "198.51.100.66")).Response.StatusCode);
}
var customer = (await Login(server, "198.51.100.7")).Response.StatusCode;
Assert.Equal([.. Enumerable.Repeat(401, 10), 429], attacker);
Assert.Equal(401, customer); // wrong password, but not locked out by someone else's attempts
}
[Fact]
public async Task Front_Door_id_is_enforced_when_configured()
{
var server = factory.WithWebHostBuilder(b => b.UseSetting("FrontDoor:Id", "8f4b1c2e-0000-4000-8000-000000000001")).Server;
var withoutId = await server.SendAsync(c => c.Request.Path = "/api/books");
var withId = await server.SendAsync(c =>
{
c.Request.Path = "/api/books";
c.Request.Headers["X-Azure-FDID"] = "8f4b1c2e-0000-4000-8000-000000000001";
});
Assert.Equal(403, withoutId.Response.StatusCode);
Assert.NotEqual(403, withId.Response.StatusCode);
}
}
// tells TestServer that this hand-built request has a body
file sealed class RequestHasBody : IHttpRequestBodyDetectionFeature
{
public bool CanHaveBody => true;
}
$ dotnet test --logger "console;verbosity=normal"
Passed Bookstore.Api.Tests.ForwardedHeadersTests.A_Cloudflare_edge_is_trusted [177 ms]
Passed Bookstore.Api.Tests.ForwardedHeadersTests.Anyone_else_is_not_trusted [1 ms]
Passed Bookstore.Api.Tests.EdgeProtectionTests.The_login_limit_is_per_visitor_not_per_edge [182 ms]
Passed Bookstore.Api.Tests.EdgeProtectionTests.Front_Door_id_is_enforced_when_configured [43 ms]
Total tests: 4
Passed: 4
Total time: 0.5040 Seconds
The attacker gets a 429 after the tenth attempt; the customer coming through the same edge gets a 401 on their own allowance, meaning they are not locked out. This layer also works when someone goes to the origin directly, bypassing the edge:
$ for i in $(seq 1 11); do code=$(curl -sk -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"wrong"}' https://127.0.0.1:5443/api/auth/login); printf "%s " "$code"; done; echo
401 401 401 401 401 401 401 401 401 401 429
The two layers' limits differ on purpose. The edge is strict and cheap, the origin looser and the last line of defence. The same logic applies to order creation (POST /api/orders), but with a higher limit: Nevin Hanım's school, placing bulk orders through the institutional portal, comes with dozens of users behind a single NAT IP. A harsh per-IP edge limit there blocks a real group of customers.
DDoS, Bot Fight Mode and the UX Cost of Challenges
A DDoS attack comes at two levels. At the network level (L3/L4) the goal is to fill bandwidth or the server's connection table; the application never sees this traffic, and the only defence is a network big enough to absorb the attack before it reaches the origin. At the HTTP level (L7) the requests look valid and target expensive endpoints: search, cart calculation, PDF generation. According to Cloudflare's documentation, unmetered DDoS protection from L3 to L7 is included on all plans. This means Bookstore doesn't need a separate DDoS product, but if the origin isn't locked down, this protection is bypassed too.
Bot Fight Mode is a feature, offered on the Free plan as well, that sends expensive challenges to traffic matching known bot patterns. The limits in its documentation matter: it runs for the whole domain, can't be turned off per path and can't be skipped with custom rules, because it runs outside the WAF's rule engine. It can also challenge API and mobile app traffic. Since Bookstore's Angular app runs in the browser, this is usually fine. But if institutional customers' integrations or a monitoring tool call /api/, they can be blocked. Super Bot Fight Mode on Pro and above runs in the WAF's rule engine and allows exceptions for specific traffic with skip rules. For a system with a public API, that difference is decisive.
Challenges like Turnstile were designed to replace CAPTCHAs and stay invisible for most users. Still, every challenge means a wait, sometimes a click, and an extra obstacle for people using screen readers. The rule: put challenges on risky actions and suspicious signals, not on every page. They make sense on the login form after a few failed attempts, on the sign-up form and on campaign code entry; not on catalog pages. We cover the accessibility side in the Accessibility (a11y) series.
Locking Down the Origin
Back to the attacker from Guess First. The origin's IP leaked through one of the routes we listed in the sixth post. None of the WAF, rate limit or bot rules at the edge see this traffic. The only answer is for the origin to accept connections only from the CDN. There are four ways to do this, and they complement each other.
IP allowlist: in the firewall in front of the origin (a network security group in the cloud, Nginx or the operating system's firewall on a server) allow only Cloudflare's ranges. A script that generates Nginx rules from the cloudflare-ips.txt of the sixth post:
#!/bin/sh
# Turns cloudflare-ips.txt into nginx allow rules; everything else is denied.
# Usage: make-nginx-allowlist.sh cloudflare-ips.txt > cloudflare-allow.conf
echo "# generated from Cloudflare's published ranges, do not edit"
while read -r range; do
[ -n "$range" ] && echo "allow $range;"
done < "$1"
echo "deny all;"
# origin-side lock: only Cloudflare's ranges may connect
server {
listen 8090;
server_name bookstore.example;
include /etc/nginx/cloudflare-allow.conf;
location / {
proxy_pass https://origin_tls;
}
}
$ ./make-nginx-allowlist.sh cloudflare-ips.txt > cloudflare-allow.conf
$ head -3 cloudflare-allow.conf; tail -2 cloudflare-allow.conf
# generated from Cloudflare's published ranges, do not edit
allow 173.245.48.0/20;
allow 103.21.244.0/22;
allow 2c0f:f248::/32;
deny all;
$ curl -s -o /dev/null -w "%{http_code}\n" -H "Host: bookstore.example" http://127.0.0.1:8090/api/books
403
A request not coming from Cloudflare gets a 403. But an IP allowlist alone isn't enough, because Cloudflare's IPs belong to everyone. If another Cloudflare customer points a record in their own zone at your origin IP, their traffic also comes from Cloudflare's IPs and passes the allowlist; and it has gone through their WAF and rate limit rules, not yours. Azure Front Door's documentation gives the same warning for Front Door: other Azure customers use the same IPs.
Authenticated Origin Pulls: when connecting to the origin, Cloudflare presents a client certificate and the origin refuses the connection without validating it. This is exactly the mutual TLS we covered in the mTLS post of the SSL/TLS Certificates and HTTPS series. Per Cloudflare's documentation there are three levels. The global level uses a Cloudflare certificate shared by all accounts and only says "the request comes from Cloudflare", not "from your account". The zone and hostname levels work with a certificate you upload and are exclusive to your account. So it is the zone or hostname level with your own certificate that closes the gap above. Authenticated Origin Pulls only works with the Full and Full (strict) modes.
Tunnel: the cloudflared agent running at the origin opens an outbound connection to Cloudflare, and traffic arrives through it. The origin has no port open to the internet, and the firewall needs not a single inbound rule. A leaked IP becomes worthless. This is the cleanest option for new setups.
Azure Front Door: Microsoft's recommendation is to apply two steps together: allow only the AzureFrontDoor.Backend service tag in the origin's network security group, and check in the application that the X-Azure-FDID header on every request matches your own profile's ID. On App Service you can do both with access restrictions without touching code. If you want to do it in your own code, RequireFrontDoorId above does that, and the test shows that a request without the ID gets a 403. On the Premium tier, Private Link can also take the origin off the internet entirely.
Watching the Edge Logs
A blocked attack is a signal too. On Cloudflare, the Security Events screen shows WAF, rate limit and bot decisions one by one. But the real value is seeing these events in the same place as application logs. Logpush sends edge logs to a storage service or a SIEM; according to Cloudflare's documentation it is available on all plans, but which datasets are available depends on the products you use. On Azure Front Door, diagnostic logs and Log Analytics play the same role.
Two practical suggestions. First, write the CF-Ray header Cloudflare sends with every request into the origin's log. When a customer says "I was blocked", you link the decision at the edge to the request at the origin with that ID. Second, build alerts on change, not on counts. Signals like "429s on login are ten times normal over the last hour" or "half of the requests the WAF blocked come from one country" are the first users of the alerting setup we build in the Observability series.
In Production
Verify the origin lockdown, don't assume it. Run a job that sends a request to the origin's IP from outside, bypassing the CDN, and checks that the connection is refused. A firewall rule silently disappearing during an infrastructure change is a common occurrence.
Give rules an owner and a reason. The description of every custom rule and every exception should say why it was added and by whom. If six months later nobody can answer "why is this skip rule here?", nobody dares to remove it and the hole stays.
Make plan limits part of the architecture decision. The Free plan's single rate limiting rule and 10-second window are not enough to protect login and the order endpoint at the same time. If your needs go beyond Pro's two rules, the load on the origin's limiter grows. Upgrading the plan is sometimes the cheapest security measure.
Write the emergency plan in advance. The steps to take during an attack should be clear: Cloudflare's "Under Attack" mode, temporary challenge rules for specific countries or ASNs, a feature flag that turns off expensive endpoints at the origin. Try them in a drill rather than for the first time during an attack.
Common Pitfalls
Leaving the origin open: the situation from Guess First. All of a CDN's security features are optional for as long as the origin can be reached directly.
Relying only on an IP allowlist: other customers use Cloudflare's and Front Door's IPs too. Complete the allowlist with Authenticated Origin Pulls (with your own certificate), an X-Azure-FDID check or a tunnel.
Running the origin's rate limiter before forwarded headers: all users share a few edge IPs and a single attacker locks everyone out. UseForwardedHeaders always comes first.
Turning a new WAF rule on straight away with Block: false positives surface as customer complaints. Start with a narrow scope or Managed Challenge, watch Security Events, then tighten.
Forgetting Bot Fight Mode in front of the API: institutional integrations and monitoring tools can't solve challenges. For a system with a public API, consider Super Bot Fight Mode, which allows exceptions.
A Small Experiment
★☆☆ Change the lab edge's login limit from rate=5r/m to rate=1r/s and repeat the same loop. How many requests reach the origin? What role does burst play?
★★☆ Add a second policy to EdgeProtection for POST /api/orders: 30 requests per minute per IP. What happens in a bulk operation of 40 orders from the same institution (the same IP)? What changes if the partition key is the signed-in user's ID instead of the IP?
★★★ Configure the origin's Kestrel to require a client certificate and give the lab edge a client certificate with proxy_ssl_certificate. Verify that a connection without a certificate is refused and the edge with a certificate gets through. Which level of Authenticated Origin Pulls on Cloudflare does this setup correspond to?
The series ends here. We bought Bookstore's domain and put measures in place so we don't lose it, wrote the DNS records for the web, the API and mail, learned to change DNS without downtime and to move DNSSEC, built the CDN's caching logic, moved to Cloudflare and locked down the edge and the origin. Each of these layers is the foundation for the next step: managing infrastructure as code with Terraform, monitoring these layers with Observability and setting up the Azure side in Cloud Fundamentals all continue from this series.