TL;DR
The Cloudflare free plan gives you 5 WAF rules per zone. Turn each blocked IP into its own rule and you run dry almost at once. So we don't. One rule points at a Cloudflare List instead, and a list holds 10,000 IPs. You get 10 lists per account, which puts the real ceiling near 100,000 addresses behind that single rule. Our .NET 10 service writes every auto-blocked IP into the list. The push is fire-and-forget, so the visitor never waits on it. The hard part is the bulk endpoint: it answers asynchronously, and it never hands back the item ID you need to delete the entry later. That trap is most of this article.
Author's note: Everything here — the code, the schema, the numbers — comes off the live bilalkose.com.tr system. The ICloudflareIpListService, the IpBlocklist table and the push loop have been in production since May 2026. The origin classifier writes every IP it auto-blocks into one Cloudflare List. Pillar 1 mentioned IP blocklist push in passing; this goes deeper — the scaling, the bulk endpoint that bites back, the cleanup that closes the loop.
Contents
- Why a Cloudflare List? The 5-Rule Limit vs 100K-IP Capacity
ICloudflareIpListService: Bootstrap, Add, Remove- The Async Bulk Add:
operation_idvsitem_id - The Auto-Block Loop: Fire-and-Forget Edge Push
- The Admin Panel: One-Click and Bulk Push
- The TTL Expirer: Why We Store
CloudflareListItemId - Frequently Asked Questions
- Related Articles
Why a Cloudflare List? The 5-Rule Limit vs 100K-IP Capacity
Pillar 1 covered IP blocklist push in a single sentence: send the suspect IP to Cloudflare and let the edge stop it. This article opens up the scaling problem hiding under that sentence.
The first instinct is to write one Custom Rule per IP — ip.src eq 1.2.3.4, action block. A few IPs in, you hit the wall. The Cloudflare free plan gives you only 5 Custom Rules per zone (25 on Pro). For a 15-IP blocklist that is a non-starter. And turning each IP into a rule means a rule CRUD call to the Cloudflare API on every block and every expiry, which burns through the rate limit of 1,200 requests per 5 minutes per token fast.
The right shape is one rule plus a Cloudflare List. A List is an account-level collection of IPs, referenced inside a WAF rule with the expression ip.src in $bilalkose_auto_block. A single list holds 10,000 IPs, and you can open 10 lists per account — an effective 100,000-IP capacity, all from one WAF rule slot. Whether you have 15 IPs or 9,000, the same rule blocks every one of them; the rule count never changes.
| Approach | Capacity (free plan) | WAF rule slots | Update cost |
|---|---|---|---|
| One Custom Rule per IP | 5 IPs | 5/5 used | Each IP = 1 rule CRUD call |
| One list + one rule | 10,000 IPs × 10 lists | 1/5 | Add/remove a list item, rule stays put |
Our setup runs one list and the single ip.src in $bilalkose_auto_block rule that references it. The origin classifier writes every IP it auto-blocks into that list, and the WAF rule is never touched again.
ICloudflareIpListService: Bootstrap, Add, Remove
Every CF List operation flows through one interface. It carries the same design language as the ICloudflareWafRulesService from B1: small, intent-revealing, returning immutable types.
public interface ICloudflareIpListService
{
/// <summary>List ID — populated after InitializeAsync runs at startup.</summary>
string? ListId { get; }
bool IsReady { get; }
/// <summary>One-time startup call — creates the list if it does not exist.</summary>
Task InitializeAsync(CancellationToken ct);
/// <summary>For auto-block: add an IP to the Cloudflare list.
/// On success, returns the item ID Cloudflare assigned (stored in the DB, needed to delete it later).</summary>
Task<string?> AddBlockedIpAsync(string ip, string reason, CancellationToken ct);
/// <summary>For the TTL expirer: delete a list item.</summary>
Task<bool> RemoveBlockedIpAsync(string itemId, CancellationToken ct);
}
There are three behavioural points. InitializeAsync bootstraps the list at startup — it creates the list if it is missing, idempotently. AddBlockedIpAsync adds a block and returns an item ID. RemoveBlockedIpAsync deletes by that item ID. The IsReady flag lets the service fall quietly into a no-op mode when the token or list ID is missing, so the origin classifier keeps working even if the Cloudflare integration is down. Edge push is one layer of defence, not the only line.
The DI registration is a singleton:
// Typed HttpClient + Bearer auth (set in the CloudflareApiClient constructor)
services.AddHttpClient<CloudflareApiClient>();
// Singleton state (ListId, ZoneId cache) — shared across the app lifetime
services.AddSingleton<ICloudflareZoneResolver, CloudflareZoneResolver>();
services.AddSingleton<ICloudflareIpListService, CloudflareIpListService>();
The service is a singleton because the ListId is resolved once and cached for the lifetime of the app — re-querying the list on every request would be wasteful. The CloudflareApiClient underneath is a typed HttpClient: its base address, 30-second timeout and Authorization: Bearer {token} header are set in the constructor, and the token comes from Vault.
The Async Bulk Add: operation_id vs item_id
This is the most maddening detail in the whole article. Cloudflare's list item add endpoint — POST /accounts/{id}/rules/lists/{list}/items — is both bulk and asynchronous. Even when you send a single IP, the body is an array:
public async Task<string?> AddBlockedIpAsync(string ip, string reason, CancellationToken ct)
{
if (!IsReady) return null;
if (string.IsNullOrWhiteSpace(ip)) return null;
var comment = Truncate(reason, CommentMaxLen);
var body = new[] { new CfListItemRequest(ip, comment) };
// POST is a bulk operation; it accepts many items, we send one IP
var resp = await _api.PostAsync<CfBulkOperation>(
$"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items",
body, ct).ConfigureAwait(false);
if (resp?.Success != true)
{
LogCfErrors($"add-ip:{ip}", resp);
CloudflareMetrics.IpListItemsAddedTotal.WithLabels("error").Inc();
return null;
}
Here is the trap: this endpoint does not return the ID of the item you added. Because it is a bulk operation, it returns an operation_id — meaning "the job has been queued". But we have to store the item ID in the database; when the TTL expires we need that ID to delete the item, and the IP alone will not do. The fix is to query the list for the item right after adding it:
// The bulk endpoint is async — it returns an operation_id. To get the
// item ID, query the list again, or use GET items?search=ip.
var search = await _api.GetAsync<List<CfListItem>>(
$"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items?search={Uri.EscapeDataString(ip)}",
ct).ConfigureAwait(false);
if (search?.Success == true && search.Result is { Count: > 0 })
{
var item = search.Result.FirstOrDefault(i =>
string.Equals(i.Ip, ip, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
_logger.LogInformation("[CF IpList] Added {Ip} -> item={ItemId}", ip, item.Id);
CloudflareMetrics.IpListItemsAddedTotal.WithLabels("success").Inc();
return item.Id;
}
}
// We could not resolve the item ID but the add looked fine — return operation_id as a fallback.
_logger.LogWarning("[CF IpList] Added {Ip} but could not resolve item id", ip);
CloudflareMetrics.IpListItemsAddedTotal.WithLabels("success").Inc();
return resp.Result?.OperationId;
}
Two steps: add with the POST, then find the item with GET items?search={ip} and return its ID. The match has to use OrdinalIgnoreCase — IPv6 addresses can come back without case normalisation. When the search finds nothing, we return the operation_id as a fallback; the ID will not be exact, but the CloudflareListItemId column in the DB stays non-null, so the bulk push will not retry the same IP. The Inc() calls feed a Prometheus counter — the bilal_cloudflare_iplist_items_added_total{status} metric shows the add and error rate in Grafana.
The Auto-Block Loop: Fire-and-Forget Edge Push
When the origin classifier catches an attack probe and the threat threshold is crossed, it writes the IP into the dbo.IpBlocklist table. The schema:
CREATE TABLE dbo.IpBlocklist (
RawIp NVARCHAR(45) NOT NULL,
BlockedAt DATETIME2(0) NOT NULL CONSTRAINT DF_IpBlocklist_BlockedAt DEFAULT SYSUTCDATETIME(),
ExpiresAt DATETIME2(0) NOT NULL,
Reason NVARCHAR(200) NOT NULL,
ThreatScore INT NOT NULL,
HitCount INT NOT NULL CONSTRAINT DF_IpBlocklist_HitCount DEFAULT 1,
LastHitAt DATETIME2(0) NOT NULL CONSTRAINT DF_IpBlocklist_LastHitAt DEFAULT SYSUTCDATETIME(),
CloudflareListItemId NVARCHAR(64) NULL,
CONSTRAINT PK_IpBlocklist PRIMARY KEY CLUSTERED (RawIp)
);
The CloudflareListItemId column is the pivot — it stores the item ID we returned in the previous section. Once the DB INSERT finishes, the Cloudflare push fires fire-and-forget; making the visitor wait for the Cloudflare API before they get their 403 would be pointless:
// Cloudflare edge-block — fire-and-forget, do not delay the request's 403.
// On success, CloudflareListItemId is written to the DB (the TTL expirer uses it to delete).
var ipCopy = Truncate(ip, 45) ?? ip;
var reasonCopy = reason ?? string.Empty;
_ = Task.Run(() => PushToCloudflareAsync(ipCopy, reasonCopy));
}
The Task.Run call is assigned to a discard. PushToCloudflareAsync opens its own DI scope — the correct way to reach a scoped service from singleton middleware — pushes, and writes the returned ID back to the DB:
private async Task PushToCloudflareAsync(string ip, string reason)
{
try
{
using var scope = _serviceProvider.CreateScope();
var cf = scope.ServiceProvider.GetService<ICloudflareIpListService>();
if (cf is null || !cf.IsReady) return;
var itemId = await cf.AddBlockedIpAsync(ip, reason, CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrEmpty(itemId)) return;
const string updateSql = @"
UPDATE dbo.IpBlocklist
SET CloudflareListItemId = @id
WHERE RawIp = @ip AND (CloudflareListItemId IS NULL OR CloudflareListItemId <> @id);";
using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync().ConfigureAwait(false);
using var cmd = new SqlCommand(updateSql, conn);
cmd.Parameters.AddWithValue("@id", itemId);
cmd.Parameters.AddWithValue("@ip", ip);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error($"[TrafficClassifier] CF list push failed for {ip}: {ex.Message}");
}
}
The whole method is wrapped in a try/catch. Even if the CF push fails, the IP is already in IpBlocklist — the origin classifier serves the next request a 403 from a 60-second cache lookup. Edge push is an optimisation, not the only line of defence. The first request reaches the origin once inside the 24-hour window; if the edge push succeeded, every request after that stops at Cloudflare and never sees the origin.
The Admin Panel: One-Click and Bulk Push
The automatic loop handles most of the work, but two situations still call for a manual push: records that piled up while the CF integration was down, and an IP added by hand. The /admin/security/ip-blocklist page has two actions for exactly this.
A single IP push:
[HttpPost("{ip}/push-cf")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PushCf(string ip, CancellationToken ct)
{
if (!IsValidIp(ip))
{
SetErrorMessage("Invalid IP address.");
return RedirectToAction(nameof(Index));
}
if (_cf is null || !_cf.IsReady)
{
SetErrorMessage("Cloudflare integration is not ready (token / list).");
return RedirectToAction(nameof(Index));
}
var actor = User.Identity?.Name ?? "admin";
try
{
var itemId = await _cf.AddBlockedIpAsync(ip, $"admin_push by {actor}", ct);
if (string.IsNullOrEmpty(itemId))
{
SetErrorMessage($"{ip} Cloudflare push failed (no item id returned).");
return RedirectToAction(nameof(Index));
}
await UpdateCfItemIdAsync(ip, itemId, ct);
SetSuccessMessage($"{ip} added to the Cloudflare list.");
}
catch (Exception ex)
{
_logger.Error($"[IpBlocklistAdmin] PushCf failed for {ip}: {ex.Message}");
SetErrorMessage($"{ip} push error: {ex.Message}");
}
return RedirectToAction(nameof(Index));
}
The bulk push sends the records not yet on Cloudflare in one pass — the CloudflareListItemId IS NULL filter is the key:
-- Pull records not yet on CF and not yet expired
const string selectSql = @"
SELECT RawIp FROM dbo.IpBlocklist
WHERE ExpiresAt > SYSUTCDATETIME() AND CloudflareListItemId IS NULL;";
ExpiresAt > SYSUTCDATETIME() skips expired records, and CloudflareListItemId IS NULL skips the ones already pushed — which makes the bulk push idempotent. Press it twice and the second run reports "nothing to push". The foreach loop calls AddBlockedIpAsync plus a DB UPDATE for each IP, counting pushed and failed; if one IP blows up, the loop carries on with the rest.
The TTL Expirer: Why We Store CloudflareListItemId
Automatic blocks are not permanent — IpBlocklist.ExpiresAt carries a 24-hour TTL. The IpBlocklistExpiryHostedService, which runs once an hour, processes the records where ExpiresAt < NOW: it DELETEs them from the DB and removes the Cloudflare list item.
This is the one and only reason we store CloudflareListItemId. Cloudflare's list item delete endpoint asks for the item ID, not the IP:
public async Task<bool> RemoveBlockedIpAsync(string itemId, CancellationToken ct)
{
if (!IsReady) return false;
if (string.IsNullOrWhiteSpace(itemId)) return false;
var body = new { items = new[] { new { id = itemId } } };
var resp = await _api.DeleteAsync<CfBulkOperation>(
$"accounts/{_api.Options.AccountId}/rules/lists/{ListId}/items",
body, ct).ConfigureAwait(false);
if (resp?.Success == true)
{
_logger.LogInformation("[CF IpList] Removed item {ItemId}", itemId);
CloudflareMetrics.IpListItemsRemovedTotal.WithLabels("success").Inc();
return true;
}
LogCfErrors($"remove-item:{itemId}", resp);
CloudflareMetrics.IpListItemsRemovedTotal.WithLabels("error").Inc();
return false;
}
Remember the extra GET we made in the bulk-add section to resolve the item ID — this is where it pays off. If we had not captured the ID at push time, the expirer would have no way to know which item to delete, and the Cloudflare list would grow without bound: blocks expire on the origin after 24 hours but would sit on the edge forever. In the rare case where the search failed and we fell back to the operation_id, the delete fails too; those records linger a little longer on the CF side. That is harmless — the IP has already expired on the origin, and an extra block at the edge only errs on the stricter side.
Frequently Asked Questions
Is Cloudflare List available on the free plan?
Yes. Account-level Lists are on the free plan: 10,000 items per list, 10 lists per account. On the WAF side, the rule that references the list takes just one slot out of the free plan's 5-Custom-Rule quota. So a 100,000-IP capacity from a single slot is doable on the free plan.
What is the downside of using a List instead of per-IP rules?
List items cannot each take a different action or log setting — every IP in the list shares the action of the rule that references it. If you need different behaviour for different IP groups (block one set, challenge another), you open separate lists and separate rules. Our need is a single action — block — so one list is enough.
If the bulk endpoint is async, when does my added IP go live?
After the operation_id comes back, the item usually appears in the list within seconds; the WAF rule picks it up in under 30 seconds once worldwide propagation finishes. Because we add a single IP, the queue is processed almost immediately — which is why the search call right after the add already sees the item.
If the fire-and-forget push fails, is the IP left unblocked?
No. The IP is already in dbo.IpBlocklist; the origin classifier serves the next request a 403 from a DB/cache lookup. Edge push is only an optimisation that spares the origin. A push error gets logged, and the bulk push action in the admin panel mops up the remaining CloudflareListItemId IS NULL records later.
Related Articles
- Pillar 1 — Cloudflare Edge Security and .NET: WAF + IP Blocklist on K8s — the pillar this cluster belongs to; this is the deep dive on its IP Blocklist push section.
- B1 — Cloudflare WAF API Custom Rules: Full CRUD Management from .NET 10 — managing the WAF rule that references the list is covered there.
- Cross-pillar — .NET Security Telemetry on K8s: Detecting Real Visitors — the origin classifier and ThreatScore side that leads up to blocking an IP.
Comments (0)
No comments yet. Be the first to comment.