IP blocking is dead: what the Read the Docs DDoS teaches .NET teams
Read the Docs took 5.5 million requests per minute for ten days. The attack walked straight past IP blocks. Here is how to build layered abuse defense in ASP.NET Core.
Jean-Pierre Broeders
Freelance .NET Developer
Read the Docs published a write-up this week about the worst DDoS they have ever taken. Ten days. A peak of 5.5 million requests per minute, roughly a hundred times their normal traffic. It hit their docs sites, their commercial hosting, and their author dashboards at the same time. The post-mortem landed on the Hacker News front page, and the comments split the way these always do. Half of them said "just put Cloudflare in front of it." The other half had run something in production and knew better.
The half that knew better is right, and the reason is the one line worth stealing from the whole report: blocking by IP address is over. The attack came from millions of unique IPs across hundreds of networks. Every request had randomized headers and randomized TLS parameters. There was no address list to ban, no signature to match. If your abuse defense still starts with "we'll block the bad IPs," you are defending against 2015.
I want to walk through what actually stopped it, and then translate the whole thing into ASP.NET Core, because most of the pieces are things you already have and probably have configured wrong.
Why the IP block failed
Think about what an IP-based rate limit assumes. It assumes an attacker has a small number of addresses and sends a lot of traffic from each one. You count requests per address, and when one crosses the line you drop it. That model held for a long time.
It does not hold now. Residential proxy networks and botnets give an attacker a fresh IP for almost every request. Read the Docs saw requests spread so thin across so many addresses that no single IP ever looked abnormal. Each one sent a handful of requests and moved on. Your per-IP counter never trips, and meanwhile the aggregate is a hundred times your baseline.
The attackers also did their homework on caching. They deliberately went after cache-miss URLs: 404s, 302 redirects, anything that had to reach the origin instead of getting served from the edge. That is the smart move. A cached page costs the attacker nothing and costs you nothing. A cache miss costs you a database hit, a render, a chunk of CPU. So they hunted for the surfaces that miss.
And they ran what the report calls a yo-yo pattern. Ramp up until something starts rate limiting, note the threshold, back off, wait, come back under it. They were probing your limits the way you would probe a login form. This is the part people underestimate. A DDoS in 2026 is not a dumb flood. It adapts.
The three things that actually worked
Strip the report down and three ideas carried the defense.
First, cache aggressively, even for a few minutes. Their line is blunt and correct: "even setting a short cache window of a few minutes will ensure resources can't attack infrastructure." If a URL can be served from the edge, the attacker can hammer it all day and never touch your origin. The whole game is moving the cache-miss surface as close to zero as you can.
Second, rate limit on what a request looks like, not where it came from. They used bot probability scores and fingerprints: TLS anomalies, header ordering, request shape. That is the answer to the millions-of-IPs problem. You stop counting addresses and start scoring behavior.
Third, always leave real users an escape hatch. Their worst response was a JavaScript challenge, not a hard block. Solve it once and you are trusted for a day. A real browser passes without noticing. A dumb bot cannot run the JS and falls over. You get filtering without a wall of 403s in front of your actual customers.
Now the .NET part.
Get the real client IP first, or everything else lies
This is the mistake I see most, and it quietly breaks every other control. When you sit behind Cloudflare or any reverse proxy, HttpContext.Connection.RemoteIpAddress is the proxy, not the visitor. Every rate limit partition you build on top of it lumps all of your traffic into one bucket. You either rate limit the whole planet as one client or you rate limit nobody.
Fix it before you do anything else. Trust the forwarded headers, and only from your proxy:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Trust only Cloudflare's ranges. Never leave this empty in production;
// an empty list means anyone can spoof X-Forwarded-For.
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
foreach (var range in CloudflareRanges.V4)
options.KnownNetworks.Add(range);
});
var app = builder.Build();
app.UseForwardedHeaders();
If you front everything with Cloudflare, prefer their CF-Connecting-IP header, which is a single value and cannot be chained the way X-Forwarded-For can:
static string ClientKey(HttpContext ctx)
{
var cf = ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(cf))
return cf;
return ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
Get this wrong and the rest of the article is theater.
Rate limiting the way .NET actually ships it
Since .NET 7 the framework has a real rate limiter built in, and most teams still hand-roll something worse. Use AddRateLimiter. A sliding window per client, plus a global concurrency ceiling so no single endpoint can eat the whole process:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-client", httpContext =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: ClientKey(httpContext),
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 0
}));
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.Headers.RetryAfter = "60";
await context.HttpContext.Response.WriteAsync(
"Rate limit exceeded.", token);
};
});
app.UseRateLimiter();
Wire the policy onto the endpoints that actually cost you something:
app.MapGet("/search", SearchHandler).RequireRateLimiter("per-client");
That is a fine baseline. It is also exactly what the yo-yo pattern is designed to defeat. A fixed 60-per-minute limit is a number the attacker will find and sit just under. So do not stop here.
Score the request, do not just count it
The Read the Docs lesson translated to code is this: your partition key should carry a signal about how trustworthy the caller is, and the limit should change based on that signal. Cloudflare hands you a bot score on the request. Use it.
static int PermitFor(HttpContext ctx)
{
// Cloudflare bot management score: 1 = almost certainly a bot,
// 99 = almost certainly human. Adjust the bands to your traffic.
var raw = ctx.Request.Headers["Cf-Bot-Score"].FirstOrDefault();
if (int.TryParse(raw, out var score))
{
if (score < 30) return 5; // very likely a bot: tiny budget
if (score < 60) return 30; // suspicious: modest budget
}
return 120; // looks human: generous budget
}
options.AddPolicy("scored", httpContext =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: ClientKey(httpContext),
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = PermitFor(httpContext),
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 0
}));
Now a client that smells like a bot gets five requests a minute and a client that looks like a person gets a hundred and twenty. The attacker's yo-yo probing finds a limit, but it is their limit, not your customer's. This is the whole shift from "where did it come from" to "what does it look like." You are not blocking anyone. You are pricing them.
If you do not have Cloudflare, you can build a rough version of the same signal yourself: missing Accept-Language, a User-Agent that never varies across thousands of requests, header ordering that no real browser produces. It is cruder, but the principle holds.
Kill the cache-miss surface
Rate limiting is your second line. Caching is your first, and it is cheaper. The attack targeted cache misses on purpose, so your job is to make sure the URLs they can reach are boring, cheap, and cached.
.NET has output caching since .NET 7. It is not the old response cache, it lives on the server, and you can vary and evict it properly:
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(b => b.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("public-read", b => b
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByQuery("page", "q")
.Tag("public"));
});
app.UseOutputCache();
app.MapGet("/docs/{slug}", DocsHandler).CacheOutput("public-read");
Two things matter here. One, even a five-minute window turns a would-be origin hit into an edge hit for almost every repeat request. That is the Read the Docs point exactly. Two, tag your entries so you can invalidate on write instead of setting a short TTL out of fear:
public async Task InvalidatePublic(IOutputCacheStore store, CancellationToken ct)
{
await store.EvictByTagAsync("public", ct);
}
And pay attention to your 404s. An unmatched route that runs your full middleware stack and hits the database to "check if maybe it exists" is a gift to an attacker who is deliberately requesting garbage URLs. Make not-found cheap. Short-circuit early, cache the negative result, do not let a 404 cost more than a 200.
The escape hatch is a feature, not a fallback
The last idea is the one engineers resist because it feels like giving up. A hard block is satisfying. It is also blunt, and it catches real people. Read the Docs made their worst-case response a challenge, not a wall, and that is the right instinct.
In .NET terms: your rejection path should degrade, not slam the door. Return a 429 with an honest Retry-After. If you run a challenge layer, send the borderline traffic there instead of to a 403. Log the rejection with the client key and the bot score so you can actually see the pattern the next morning. A block you cannot observe is a block you cannot tune, and tuning is the entire job during an active incident.
Manage all of this as code. Read the Docs ran their Cloudflare rules through Terraform so they could ship a change in seconds while under fire. The same discipline applies to your rate limit numbers and cache windows: they belong in config you can deploy, not in a value someone typed into a portal at 2am and cannot remember.
None of this is exotic. Forwarded headers, the built-in rate limiter, output caching, a bot score you are already paying Cloudflare for. The shift is in your head, not your stack. Stop asking where a request came from. Start asking what it looks like, how much it costs you to answer, and whether you can serve it from the edge instead.
Frequently asked questions about DDoS defense in ASP.NET Core
Why does blocking by IP address no longer work against modern DDoS? Because attackers now spread traffic across millions of residential proxy and botnet IPs, so each individual address sends only a handful of requests and never looks abnormal, while the aggregate can be a hundred times your baseline. There is no small set of bad addresses left to ban.
What is a cache-miss surface and why do attackers target it? A cache-miss surface is any URL that cannot be served from the edge and must reach your origin, such as 404s, redirects, and personalized pages. Attackers hunt these because a cached page costs them and you almost nothing, while a cache miss forces a database hit or a render and burns real CPU on your servers.
Does the built-in .NET rate limiter replace Cloudflare or a WAF? No, it complements them. The edge stops the bulk volume and gives you signals like a bot score, while the in-process limiter enforces per-client budgets on the requests that get through, protects specific expensive endpoints, and stays in place even if edge rules are misconfigured.
Why must I configure ForwardedHeaders before rate limiting behind a proxy? Because without it every request appears to come from your proxy's IP, so every rate limit partition collapses into one bucket and you either throttle all traffic together or none of it. You must resolve the real client IP first, and only trust forwarded headers from known proxy ranges to prevent spoofing.
Is a JavaScript challenge better than a hard block? For borderline traffic, usually yes, because a real browser solves it silently and stays trusted for a while, whereas a dumb bot cannot run the script and drops out. A hard 403 catches real users too, so reserve it for traffic you are certain about and send the uncertain traffic to a challenge instead.
Further reading
- The security embargo is dead: patching in the age of AI-generated exploits
- The valley of webhooks: what to build when delivery is at-least-once
- SSL Certificate Expired? How to Check, Renew and Prevent It
Conclusion: The Read the Docs attack is not a story about volume, it is a story about identity. When an attacker has a fresh IP for every request, the only defense left is to judge requests by how they behave and how much they cost you to serve. Cache hard, score instead of counting, and leave real people a way through.
Sources: Read the Docs post-mortem · Hacker News discussion.
