EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Network / Cloudflare Token Error 9109 Despite an IP Allowlist: IPv6
Network

Cloudflare Token Error 9109 Despite an IP Allowlist: IPv6

by Ton-Or ·27 Oct 2026·11 min read
Cloudflare Token Error 9109 Despite an IP Allowlist: IPv6

An allowlist containing only IPv4 works fine until one machine chooses IPv6, and everything breaks even though every IP in the list is correct. Guessing from ISP names does not help. What helps is examining evidence of who actually called the API and where the requests came from.

1-minute summary

Symptom / problem
A Cloudflare token locked by IP allowlist returns error 9109 on one machine even though the ISP range is listed.
Who should read
People using IP-restricted Cloudflare API tokens from several machines or ISPs.
Fix in short
Read the audit log to see the real caller, find that the machine goes out over IPv6, and add the ISP IPv6 /32 to the allowlist without changing the token.
Jump to steps

I had been using a Cloudflare token restricted by an IP allowlist for a while when another machine (which I call host-b in this article) suddenly could not fetch statistics and got error 9109. My main machine could still use the same kind of token normally, and all the IPs I knew belonged to the same ISP were already in the list. I had to work through what exactly did not match. This article follows the order in which I made the wrong guesses, then gathered evidence until I found the cause. I have replaced all IPs, ranges, ASNs, and token names in this article with example values.

Symptoms: the same token works on one machine, but another gets 9109

I restricted the token with Client IP Address Filtering because my home connection uses CGNAT, and its outbound IP can change frequently. Leaving it unrestricted would mean a leaked token could be used from anywhere. I wanted it to work only from the network of the ISP I use. I briefly explained why I needed entire ranges instead of a single IP in a callout in my article on DNS and grey mail records. This article is a follow-up that goes deeper into "how to find the ranges" and "the traps that remain."

Initially, the list had just 3 entries: two /16 ranges and one /32. It worked as long as the outbound IP fell within those two /16 ranges. The project owner noticed that on some days, the IP they saw was outside the familiar group, and when host-b started using the token too, it immediately got 9109.

3Entries in the original list
5 IP / 9 daysOutbound IPs from the IP monitor
586Prefixes announced by the ASN
417IPs in 17 months of audit logs

Two wrong guesses before I had evidence

My first guess was that ISPs in the same corporate group might use different ASNs (the mobile operator and another ISP that had merged). I looked up the group's blocks and added them to the list. Once that was done, it turned out to have nothing to do with host-b's problem. The 9109 remained.

My second guess was that the IPs the project owner saw "outside the group" probably belonged to someone else's mobile connection, VPN, or WARP. This was easier to check than I expected because I already had a monitor tracking our machine's outbound IP. Looking back over 9 days, our machine had used 5 outbound IPs, and all 5 were in the same pool. None fell outside the allowed /16 ranges.

Guessing from ISP names got me nowhere

Both of my guesses followed the same pattern: I started with the provider's name and reasoned from there, with nothing to prove whether I was right or wrong. I avoided wasting more time by turning to two verifiable datasets. The first was the prefixes the ASN actually announced. The second was the audit log showing who accessed this account and from which IPs.

Finding the ISP's IP ranges through RIPEstat instead of guessing

RIPEstat has an announced-prefixes endpoint that lists the prefixes an ASN is announcing. Supply the ISP's ASN, and it returns the actual list currently announced on the internet. I use AS64496 as an example in this article. For actual use, enter your own provider's ASN.

import ipaddress, json, urllib.request

ASN = "AS64496"   # ค่าตัวอย่าง ใส่ ASN ของ ISP ตัวเอง
url = f"https://stat.ripe.net/data/announced-prefixes/data.json?resource={ASN}"
with urllib.request.urlopen(url, timeout=30) as r:
    prefixes = [p["prefix"] for p in json.load(r)["data"]["prefixes"]]

# collapse_addresses ต้องส่งทีละ version จะปนกันไม่ได้
v4 = [ipaddress.ip_network(p) for p in prefixes if ":" not in p]
v6 = [ipaddress.ip_network(p) for p in prefixes if ":" in p]
blocks4 = list(ipaddress.collapse_addresses(v4))
blocks6 = list(ipaddress.collapse_addresses(v6))

print(len(prefixes), "prefix ->", len(blocks4), "v4 block,", len(blocks6), "v6 block")
for b in sorted(blocks4, key=lambda n: n.prefixlen)[:10]:
    print(b)

In this case, it returned 586 prefixes. Using collapse_addresses to merge overlapping or adjacent ranges reduced them to 53 blocks. These included 4 large /16 ranges missing from my list, plus another 23 smaller blocks. This was another trap: if I had immediately added every returned block to the list, I would also have unnecessarily added ranges belonging to the provider's other customers. Large ISP ranges often cover thousands of other people's networks. Allowing the token across all of them makes the allowlist much broader. Every addition to the list needs a reason.

So I used the second dataset to make that decision.

Using the audit log to separate our IPs from everyone else's

Cloudflare retains account audit logs for 17 months, including the actor's IP for every action. I fetched them and counted 417 IPs that had accessed this account, then compared them with the ISP's ranges.

import collections, ipaddress, json

# audit log ที่ export มาเป็นไฟล์ JSON (แต่ละรายการมี actor.ip)
logs = json.load(open("audit_export.json", encoding="utf-8"))
ips = collections.Counter(e["actor"]["ip"] for e in logs if e.get("actor", {}).get("ip"))

# ตัวอย่าง: ในเคสจริงเป็น /16 สองช่วง ที่นี่ใช้ /24 แทนเพื่อไม่ให้เป็นค่าจริง
ours = [ipaddress.ip_network("203.0.113.0/24"), ipaddress.ip_network("198.51.100.0/24")]

mine, others = collections.Counter(), collections.Counter()
for ip, n in ips.items():
    a = ipaddress.ip_address(ip)
    (mine if any(a in net for net in ours) else others)[ip] += n

print("ในกลุ่มที่อนุญาตแล้ว:", len(mine), " นอกกลุ่ม:", len(others))
for ip, n in others.most_common(15):
    print(f"{n:5d}  {ip}")

The result was clear. The IPs of the machines using our token appeared only in the two /16 pools already allowed. As for the IPs from two or three other ISPs that I had thought needed adding, checking what they had called showed that they belonged to DDNS updaters for other websites in the same account, rather than our machines. The conclusion was that I did not need to add them.

A thick ledger filled with IP entries and a magnifying glass examining a small highlighted group
Most of the 417 entries in the audit log were not our machines. The task was to find the small group that actually belonged to us.
Guessing from ISP names

Add every range that looks related

The list keeps growing without any way to know which entries are necessary, and the token becomes usable by more people on the same ISP for no reason.

Checking the audit log

Add only ranges proven to be ours

I can distinguish our machines' IPs from other people's IPs in the same account. The list is shorter, and every entry has an explanation.

But the audit log could only tell me which IPs belonged to our machines. It did not explain why host-b was rejected. I went back and asked what kind of address host-b's requests were actually leaving from, because everything I had compared so far was IPv4.

The real cause: host-b connected to Cloudflare over IPv6

host-b connected to Cloudflare over IPv6, using a /44 prefix from the same ISP, while the token's allowlist contained only IPv4. A machine with both options chooses which one to use to connect to the destination host. As a result, requests sent over IPv6 matched no entries and were rejected with 9109.

Why 9109 when the IP is allowed?host-bBoth v4 and v6IPv4 203.0.113.xIPv6 2001:db8::x(1) Choose pathapi.cloudflare.comCheck source IP(2) MatchToken allowlist49 entriesAll IPv4(3) ResultIPv4 listed: passesIPv6 unlisted: 9109
Two paths from the same machine. The IPv4 path matched the list, while the IPv6 path fell entirely outside it because there had never been any IPv6 entries.

To check which path a machine uses, I can use Cloudflare's own trace endpoint. Force -4 and -6, compare the results, and look at the ip= line.

curl -4 -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='
# ip=203.0.113.25

curl -6 -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='
# ip=2001:db8:b930::25

# ปล่อยให้เครื่องเลือกเอง เหมือนที่โปรแกรมทั่วไปทำ
curl -s https://www.cloudflare.com/cdn-cgi/trace | grep '^ip='

If the third command returns a value starting with 2001: or another IPv6 format, that means your program also uses IPv6, and an IPv4-only allowlist is already waiting to break. In my case, another option I considered was forcing the client to use IPv4 only. But that was just a suggestion; I did not actually do it in this case because adding the IPv6 ranges to the list was more straightforward and required no changes to host-b.

A brass padlock on a rack cabinet door marked IPv4 only, with another wide door beside it standing open
An allowlist restricted to IPv4 is like locking only one door, while the wide IPv6 door beside it has never been locked or allowed.

Adding IPv6 /32 ranges without changing the token value

I added 4 more IPv6 /32 ranges from the ISP group to the existing allowlist, bringing it to 53 entries. The token value stayed the same. I did not need to redeploy or change a single line on host-b. The only change was in the token's condition field.

{
  "condition": {
    "request_ip": {
      "in": [
        "203.0.113.0/24",
        "198.51.100.0/24",
        "2001:db8::/32"
      ],
      "not_in": []
    }
  }
}

The example above is shortened to three lines. The actual list contains all 49 IPv4 entries followed by 4 IPv6 /32 ranges.

4 steps: find ranges, verify, add IPv61 RIPEstat586 prefixesASN announces2 collapse53 blocks leftipaddress3 audit log17 months 417 IPsOurs: 2 /16 poolsRest: others4 Add IPv64 more /32 ranges53 entries totalSame token value, no host-b changesNo Write access: edit in dashboard
Four steps from finding the ranges to adding IPv6. The bottom-right box is the route to take when the current token cannot edit its own list.

I should be clear that another token of ours, used on another machine, has 49 entries, all IPv4, and I have not fixed it yet because that machine still uses IPv4. If it switches to IPv6 one day, it will break in the same way. This is a lesson I have recorded, rather than something that has already happened.

Dashboard traps I encountered while editing policies

While editing the token, I ran into three things that were not covered in the first few pages of the documentation.

  1. Editing a token through the API requires a token with Account API Tokens Write permission. If the token in hand has only Read access, it cannot edit its own list, so I have to use the dashboard.
  2. When expanding the permissions to include everything needed, a single policy can contain no more than 300 permissions. Exceeding that returns HTTP 400. I needed almost 400 groups, so I had to split them across multiple policies.
  3. An "All zones" policy with 5 account-level permissions mixed in (settings read, billing read, and similar permissions) could not be edited or split until I moved the account-level permissions into a separate policy.

I reorganized the permissions from 5 policies into 2, keeping the total at 288 permissions, then compared each entry against the original list. This matters because if even one permission goes missing during the reorganization, it will show up as a 403 the next day in a job that used to run successfully.

How can I know who has the token?

Do not put a complete token in a chat, a note, or an article. Every value in this article is an example, and an allowlist is only an additional layer, not a substitute for storing tokens securely. If I suspect a leak, I roll the token first and investigate afterward.

What I will check first every time

If an IP-restricted token gets 9109, I will work through these checks. First, check whether the affected machine uses IPv4 or IPv6 with curl -4 and -6, because this was the possibility I overlooked for the longest time. Second, check whether the allowlist covers both address families. Third, before adding any ISP's ranges, use the audit log to establish whether those ranges actually belong to our machines or to someone else in the same account. Fourth, if I need to edit policies, check the 300-permission limit and any account-level permissions mixed in before starting.

9109 check ordercurl -4 / -6Which path?->v4 and v6 in listBoth families covered->audit logBefore adding ranges->Edit in dashboardIf no Write access

Whether to force the client to use IPv4 only or add IPv6 to the list depends on what suits each environment. I chose to add IPv6 because I did not need to touch the machines already in use. If I have misunderstood how 9109 works anywhere, or you encounter another condition that produces the same error, I will come back and update this article later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first