We downloaded Apple's list at 09:40 UTC on 12 October 2026. It held 285,091 prefixes, 41,734 IPv4 and 243,357 IPv6, across 235 country codes. Inside Cloudflare's 104.28.0.0/16, 19,723 of the 65,536 addresses are on it. The other 45,813 are not, so a rule written against that block, or against Cloudflare's whole network, cannot tell a Private Relay user from anything else routed there.
Below we profile Apple's file and give a tested Python matcher for IPv4 and IPv6. We explain what can and cannot be known about WARP, and show real lookups of relay addresses, including two places where our own geolocation disagrees with Apple's. The post ends with a policy table. For the wider set of anonymizer signals, start with how VPN and proxy detection works.
How to detect iCloud Private Relay: match Apple's own list
Apple publishes every Private Relay egress prefix in one CSV, so an exact match tells you an IP is a relay egress. Apple's guide for network and server operators points servers to that list, or to a provider flag:
"Your server can recognize traffic from Private Relay by using the above list of Private Relay IP addresses or by checking how it's categorized by your geo IP database provider."
The same page names the fields to look for: "Other relevant fields may include: 'is_relay', 'is_hosting', 'privacy_service', or 'privacy_proxy'." In other words, Apple expects you to use either its list or a field like the one our API returns.
The list is also clean in a way most IP lists are not. Apple's December 2021 Private Relay overview says relay IP addresses "are not used or shared for any purpose other than to provide the Private Relay service." So an exact match has no collateral. Nobody else's traffic leaves from those addresses.
What is in Apple's egress CSV
The file lives at https://mask-api.icloud.com/egress-ip-ranges.csv. We downloaded it and parsed every row with Python's ipaddress module.
| Measure | Value (fetched 2026-10-12, 09:40 UTC) |
|---|---|
| Rows (prefixes) | 285,091 |
| IPv4 prefixes | 41,734 |
| IPv6 prefixes | 243,357 |
| IPv4 addresses covered | 105,647 |
| Distinct country codes | 235 |
| Columns | 5: prefix, country, region, city, and an empty trailing field |
| File size | 12,047,231 bytes |
| Overlapping prefixes | None |
Each row is a geofeed line. Here are two real rows from the file:
172.224.226.0/27,GB,GB-EN,London,
104.28.46.99/32,ZM,,Lusaka,
The second row has an empty region, so your parser has to accept blanks. One counting trap: wc -l prints 285,090, one short, because the file has no trailing newline. Count parsed rows instead.
Polling the file is cheap. The server returned an ETag, "6acc40fb-b7d37f", and answered our conditional request with 304 Not Modified. Apple does not say how often it updates the list, so we won't guess. Pick a refresh schedule that suits you.
Why an ASN or /16 rule is not enough
Relay egress shares networks with other traffic. In 104.28.0.0/16, 19,723 of 65,536 addresses are on Apple's list, and the rest are not. Apple's overview says the second relay "is operated by third-party partners who are some of the largest content delivery networks (CDNs) in the world." The three relays we sampled sit on two of them, Cloudflare (AS13335) and Akamai (AS36183).
Blocking AS13335 hits far more than relay users. Allowlisting it lets far more than relays through. The prefix match is the precise tool, and you can check any IP for VPN or relay to see how a single address is classified.
Why a relay IP is not a VPN IP
Private Relay hides the user's IP from the site but keeps them in their real region, covers only some traffic, and is limited to validated Apple devices on paid accounts. That is why it should not be scored like a VPN.
The design splits knowledge across two relays run by different companies. Apple runs the first, which sees the user's address but not the site. A partner runs the second, which sees the site but not the user. Apple's operator page says that, this way, no single party, Apple included, can see the details of a user's browsing.
| Hop | Sees the user's IP | Sees which site |
|---|---|---|
| User's iPhone, iPad or Mac | Yes | Yes |
| First relay (Apple) | Yes | No |
| Second relay (a partner network, e.g. Cloudflare or Akamai) | No | Yes |
| Your server | No, only the relay IP | It is the site |
Four lines from Apple's operator page change how you should treat these addresses:
- Scope. "Private Relay protects users' web browsing in Safari, DNS resolution queries, and insecure http app traffic." A relay IP does not mean a Safari session. An app making plain http requests arrives the same way.
- Region. "Private Relay preserves the region the user is in, so your server can trust the region assigned to the IP address it sees." The 2021 overview adds that it "does not allow users to represent themselves as connecting from a different country or region."
- Validation. "All connections that use Private Relay validate that the client is an iPhone, iPad, or Mac and that the customer has a valid iCloud+ subscription."
- Stability. "The relay IP address will remain stable during a browsing session while a user is interacting with your website."
A VPN lets the user pick any country and covers the whole device. Cloudflare is candid about its own product. "Technically, WARP is a VPN," it wrote when it launched WARP. We come back to WARP below.
One practical trap: some relay egress lives in data centers, so hosting flags can fire. Our lookup of 172.224.226.1, a relay on Apple's list, returns is_hosting: true. That is why relays show up as datacenter IPs, and why a blanket hosting block catches Apple customers.
Match an IP against Apple's list in Python
Load the CSV into sorted integer ranges, one list per IP version, and binary-search them. Refresh with a conditional request, and keep the last good copy if a download fails or looks wrong.
This is the exact script we ran on 2026-10-12, on Python 3.9.6. It uses only the standard library.
"""Match an IP against Apple's iCloud Private Relay egress list. Standard library only."""
import bisect, csv, ipaddress, os, tempfile, time, urllib.error, urllib.request
URL = "https://mask-api.icloud.com/egress-ip-ranges.csv"
CSV_PATH, ETAG_PATH, MIN_ROWS = "egress.csv", "egress.etag", 100_000
def load(path):
"""Parse the CSV into one sorted range list per IP version, then validate it."""
ranges = {4: [], 6: []}
with open(path, newline="") as f:
for row in csv.reader(f):
if len(row) != 5:
raise ValueError(f"bad row: {row}")
net = ipaddress.ip_network(row[0], strict=False)
ranges[net.version].append((int(net.network_address), int(net.broadcast_address), *row[:4]))
index = {}
for version, rs in ranges.items():
rs.sort()
if any(b[0] <= a[1] for a, b in zip(rs, rs[1:])):
raise ValueError(f"overlapping IPv{version} prefixes")
index[version] = ([r[0] for r in rs], rs)
return index, len(ranges[4]) + len(ranges[6])
def refresh(index=None, rows=0):
"""Fetch a new copy only if it changed. On any failure, keep serving the last good one."""
headers = {}
if index and os.path.exists(ETAG_PATH):
headers["If-None-Match"] = open(ETAG_PATH).read().strip()
fd, tmp = tempfile.mkstemp(dir=".", suffix=".part")
os.close(fd)
try:
with urllib.request.urlopen(urllib.request.Request(URL, headers=headers), timeout=60) as resp:
with open(tmp, "wb") as out:
out.write(resp.read())
etag = resp.headers.get("ETag", "")
new_index, new_rows = load(tmp)
if new_rows < max(MIN_ROWS, 0.9 * rows):
raise ValueError(f"only {new_rows} rows, keeping the old file")
os.replace(tmp, CSV_PATH) # atomic swap
with open(ETAG_PATH, "w") as f:
f.write(etag)
print(f"downloaded new list: {new_rows:,} prefixes")
return new_index, new_rows
except urllib.error.HTTPError as e:
print("not modified, keeping current list" if e.code == 304 else f"refresh failed: {e}")
except Exception as e:
print(f"refresh failed: {e}")
finally:
if os.path.exists(tmp):
os.remove(tmp)
return index, rows
def match(index, ip):
"""Return Apple's row for a relay egress IP, or None."""
try:
addr = ipaddress.ip_address(ip.strip())
except (ValueError, AttributeError):
return None
if addr.version == 6 and addr.ipv4_mapped:
addr = addr.ipv4_mapped # ::ffff:104.28.46.99 becomes 104.28.46.99
starts, rs = index[addr.version]
n = int(addr)
i = bisect.bisect_right(starts, n) - 1
if i >= 0 and n <= rs[i][1]:
return dict(zip(("prefix", "country", "region", "city"), rs[i][2:]))
return None
if __name__ == "__main__":
t0 = time.time()
index, rows = load(CSV_PATH) if os.path.exists(CSV_PATH) else (None, 0)
index, rows = refresh(index, rows)
print(f"{rows:,} prefixes ready in {time.time() - t0:.1f}s")
for ip in ["104.28.46.99", "172.224.226.1", "2a02:26f7:c880::1", "::ffff:104.28.28.41",
"162.159.192.1", "102.129.235.17", "not-an-ip"]:
print(f"{ip:22} {match(index, ip)}")
The first run, at 09:41 UTC, downloaded the file and printed:
downloaded new list: 285,091 prefixes
285,091 prefixes ready in 12.8s
104.28.46.99 {'prefix': '104.28.46.99/32', 'country': 'ZM', 'region': '', 'city': 'Lusaka'}
172.224.226.1 {'prefix': '172.224.226.0/27', 'country': 'GB', 'region': 'GB-EN', 'city': 'London'}
2a02:26f7:c880::1 {'prefix': '2a02:26f7:c880::/54', 'country': 'GB', 'region': 'GB-EN', 'city': 'London'}
::ffff:104.28.28.41 {'prefix': '104.28.28.41/32', 'country': 'AU', 'region': 'AU-SA', 'city': 'Adelaide'}
162.159.192.1 None
102.129.235.17 None
not-an-ip None
162.159.192.1 is a WARP connection address and 102.129.235.17 is a commercial VPN server, so neither should match, and neither did. We also tested the failure paths:
- Unchanged file. The second run sent the saved ETag, got a 304 and printed "not modified, keeping current list". Loading and indexing the local file took 4.3 seconds, so build the index once at startup, not per request.
- Bad download. We pointed
URLat a path that does not exist (Apple's server answered 400). Separately, we raised the minimum row count above the real one. Both times the script logged the failure, deleted its temp file and kept answering from the old copy. - Mapped addresses.
::ffff:104.28.28.41matched its IPv4 row. Dual-stack servers often log IPv4 clients in that form.
Because the file has no overlapping prefixes, one binary search per lookup is enough. The loader refuses any future file that has overlaps, and the refresh then falls back to the last good copy.
How to detect Cloudflare WARP, and why there is no list
You cannot match WARP against a public list, because Cloudflare does not publish one. WARP can only be identified by a provider that labels ranges it knows, or on the user's own device.
Cloudflare's egress policy documentation is plain about it: "Cloudflare does not publish Cloudflare One Client egress IP ranges." The "WARP IP ranges" that circulate in forum threads are something else. Cloudflare's firewall guide says of 162.159.192.0/24: "This IP is used for the consumer WARP client (1.1.1.1 with WARP) and is not required for Zero Trust services." That is where the WARP app connects. Your web server never sees those addresses as client IPs.
Does the site get the user's real address some other way? Not in normal browsing. Cloudflare's WARP FAQ says: "1.1.1.1 + WARP replaces your original IP address with a Cloudflare IP that consistently and accurately represents your approximate location. This happens regardless of whether the site is on the Cloudflare network or not."
The same FAQ documents two leaks. "WARP does not proxy WebRTC traffic." And: "If you grant a website access to your microphone or camera, traffic will bypass WARP and your original IP address will be visible."
On the device itself, https://www.cloudflare.com/cdn-cgi/trace prints a warp= field. On our machine, which does not run WARP, it read warp=off. Only the person who opens that page sees it, so it helps a support team debugging a customer's connection. It does nothing for a server scoring a visitor.
Our API returns relay_provider: "Cloudflare WARP" for Cloudflare's published WARP connection ranges. A lookup of 162.159.192.1 returned is_relay: true, relay_provider: "Cloudflare WARP", is_hosting: true and is_anycast: true, with a score of 24 and allow. That is the address WARP apps connect to, not one your server will see as a visitor.
What our API returns for Cloudflare addresses not on Apple's list
Cloudflare privacy-network egress addresses that are not on Apple's list come back labelled "Cloudflare", while Cloudflare's published WARP connection ranges come back "Cloudflare WARP". Here is 104.28.210.10. It sits in the same /16 as two of our Apple relays but is not on Apple's list. This is a trimmed copy of our lookup at 09:40 UTC on 2026-10-12, values unedited:
{
"ip": "104.28.210.10",
"asn": {
"asn": 13335,
"org": "CLOUDFLARENET - Cloudflare, Inc.",
"user_type": "consumer_privacy_network"
},
"geo": {
"country_code": "CA",
"city": "Toronto"
},
"detection": {
"is_vpn": false,
"is_hosting": false,
"is_relay": true,
"is_anonymous": false,
"relay_provider": "Cloudflare"
},
"scoring": {
"fraud_score": 9,
"recommended_action": "allow",
"detection_methods": ["relay_ip"]
}
}
| IP | On Apple's list? | relay_provider | Other fields | Score, action, methods |
|---|---|---|---|---|
| 104.28.210.10 | No | Cloudflare | AS13335, consumer_privacy_network, is_vpn false, is_hosting false | 9, allow, relay_ip |
| 104.28.28.41 | Yes (AU, Adelaide) | iCloud Private Relay | AS13335, consumer_privacy_network | 9, allow, relay_ip |
| 162.159.192.1 | No (WARP connection address) | Cloudflare WARP | is_hosting true, is_anycast true | 24, allow, datacenter_ip + no_ptr_datacenter + relay_ip |
| 102.129.235.17 (VPN, for contrast) | No | none | is_vpn true, is_anonymous true | 92, block, known_vpn_server + openvpn_open + ipsec_open |
Read "Cloudflare" as "a Cloudflare consumer-privacy egress address not attributed to Apple". It does not mean a confirmed WARP user, and we do not claim it does. Our API reference lists only two relay_provider values today, "iCloud Private Relay" and "Cloudflare WARP", so this third value is currently undocumented. We have asked the backend team to document it.
The useful point sits in the first two rows. Inside one Cloudflare /16, the exact CSV match separates Apple's relay (104.28.28.41) from other Cloudflare privacy egress (104.28.210.10). Our label agrees with Apple's list on both. Both score 9 and allow, so your policy is the same either way.
Private Relay and WARP side by side
| Question | iCloud Private Relay | Cloudflare WARP |
|---|---|---|
| Who runs it | Apple, with partner networks for the second relay | Cloudflare |
| Who can use it | iCloud+ subscribers on validated iPhone, iPad or Mac | Anyone who installs the free 1.1.1.1 app |
| What it covers | Safari, DNS queries, insecure http app traffic | The device's traffic, except WebRTC and sites given camera or microphone access |
| Public egress list | Yes, Apple's CSV | No |
| Location | Region kept; coarse city by default, broader if the user chooses | A Cloudflare IP that represents the user's approximate location |
| How a server identifies it | Exact match against Apple's CSV, or a provider's relay flag | A provider's label only |
Both can sit on AS13335, so the network alone separates neither.
What our lookups return for relay IPs
In our lookups, Private Relay addresses come back as relays, not as anonymous or VPN, and land in allow unless other evidence exists. We traced 104.28.46.99 end to end. Apple's row says Zambia, Lusaka. This is the request, as you would send it with a key:
curl https://api.geoiphub.com/v1/lookup/104.28.46.99 \
-H "x-api-key: YOUR_API_KEY"
We ran it without a key at 09:40 UTC on 2026-10-12, which the API allows for light use. Here is a trimmed copy of the response, values unedited:
{
"ip": "104.28.46.99",
"asn": {
"asn": 13335,
"org": "CLOUDFLARENET - Cloudflare, Inc.",
"asn_type": "unknown",
"user_type": "consumer_privacy_network"
},
"geo": {
"country_code": "AO",
"region": "Luanda",
"city": "Luanda",
"geo_confidence": 0.8
},
"detection": {
"is_proxy": false,
"is_vpn": false,
"is_tor": false,
"is_hosting": false,
"is_relay": true,
"is_anonymous": false,
"anonymity_level": "medium",
"relay_provider": "iCloud Private Relay"
},
"scoring": {
"fraud_score": 9,
"recommended_action": "allow",
"detection_methods": ["relay_ip"]
}
}
The relay is named, is_vpn and is_anonymous are false, and scoring.detection_methods shows the one signal that fired: relay_ip. The score is 9, so the action is allow. The geolocation says Angola, which Apple's row contradicts; we deal with that in the next section. The decision for this address is to allow it, rate-limit it as a shared address, and take its location from Apple's row.
Here are the other relays next to a real VPN:
| IP | Apple's row | Our result on 2026-10-12 |
|---|---|---|
| 172.224.226.1 | GB, GB-EN, London | AS36183 Akamai, asn_type hosting; is_relay true, iCloud Private Relay, is_hosting true; score 24, allow; datacenter_ip, no_ptr_datacenter, relay_ip; geo GB, London |
| 104.28.28.41 | AU, AU-SA, Adelaide | AS13335; is_relay true, iCloud Private Relay; score 9, allow; relay_ip; geo AU, Sydney, 0.8 |
| 102.129.235.17 (VPN) | Not on the list | AS834 IPXO; is_vpn true, is_anonymous true; score 92, block; known_vpn_server, openvpn_open, ipsec_open |
The relay flag does not lower the score. It adds to it. On 104.28.46.99, relay_ip is the only entry in detection_methods, so it accounts for the whole score of 9. 172.224.226.1 scores 24 because two datacenter signals fired as well, which leaves it one point inside the allow limit of 25. So relays land in allow unless other evidence exists. If something else counts against the address, such as an abuse listing, the score rises and the action can move to review. The how the score is built page lists the signals.
The API labels the relay. Apple's CSV is your independent check, and the matcher above runs it without us. Scoring a relay as if it were a VPN is the classic case in VPN detection false positives.
Why Private Relay users show the wrong city, including in our data
A relay IP only promises the user's region, and a coarse city by default. The only authoritative location for a relay IP is Apple's own row, and our database does not always agree with it.
Apple's operator page says the relay IP "accurately represents the client's coarse city-level location by default". Users can widen that. Apple's current iPhone User Guide lists two IP Address Location options, "Maintain General Location" and "Use Country and Time Zone". The 2021 overview describes them. The first picks addresses that "map to a roughly city-level area consistent with where the user is actually connecting from". The second picks addresses "across a broader, more regional area", and "All Relay IP addresses will still map to the user's original country and time zone."
We compared our geolocation with Apple's rows for the three relays:
| IP | Apple's row | Our geo | Agree? |
|---|---|---|---|
| 104.28.46.99 | ZM, Lusaka | AO, Luanda (geo_confidence 0.8) | No, different country |
| 104.28.28.41 | AU, AU-SA, Adelaide | AU, Sydney (geo_confidence 0.8) | Country yes, city no |
| 172.224.226.1 | GB, GB-EN, London | GB, London | Yes |
Two of the three disagree. Zambia against Angola is a country-level miss, the kind that trips a licensing or sanctions rule. Adelaide against Sydney gets the country right and the city wrong. Three IPs are not a benchmark, but they are enough to set a rule: for relay IPs, take location from Apple's row, not from any geo database, ours included. Decide on country or region, and treat the city as a hint. We have logged both mismatches with our backend team.
Should you block Private Relay or WARP? Let them through and score the session
No. Apple validates every relay client and says IP-based fraud rules "might need to be adjusted", so allow Private Relay traffic, rate-limit it like a shared carrier address, and step up only when other evidence points to risk.
Apple's operator page is direct about both points:
"Traditional fraud detection that relies on IP addresses might need to be adjusted to ensure legitimate users aren't impacted."
"Consider treating these addresses like larger carrier-grade NAT or enterprise IP addresses to better account for this type of traffic, since many Private Relay users may be assigned to a single relay IP address."
Apple also offers a replacement for the challenge page. It notes that "Common mitigations include CAPTCHAs or blocking traffic altogether," and says "Every Apple device running iOS 16, iPadOS 16, or macOS Monterey and later supports Private Access Tokens," which let a server check for a legitimate device without identifying the user.
Can fraudsters use relays? They can, but Private Relay needs an Apple device and a paid iCloud+ account. Apple says the validation "is designed to ensure that only valid Apple devices and accounts in good standing are allowed to use Private Relay." That makes it a costly way to farm accounts. WARP is a free app, so give WARP sessions a little more weight on your other signals, not a block. Harder IP-hiding problems sit elsewhere, such as mobile proxies.
One detail for your rate limiter: Apple's carrier-grade NAT advice is about how you treat the address, not about a flag. Our threat.is_cgnat was false on all three relay lookups. Key the shared-address handling on is_relay or the CSV match instead. To check an address from your own logs, paste it into the free IP lookup tool.
| What you see | Action | Why |
|---|---|---|
| IP in Apple's CSV or is_relay true, no other risk evidence | Allow; rate-limit per account or session, not per IP | Apple validates device and account, and many users share one relay IP |
| Relay or WARP IP plus a risky account signal (new account, payment country outside the IP's region, signup velocity) | Step up: a Private Access Token where supported, otherwise 2FA or an email check, not a CAPTCHA wall | The IP adds no evidence; the account signal does |
| Relay IP that also lands in review, for example because it is on an abuse list | Review | That evidence is about the address, not the relay |
| WARP-labelled IP | Allow and score the session | It is free and common; it hides the user but does not prove intent |
| Location rules (licensing, tax) on relay traffic | Decide on country or region from Apple's row, never city | Apple keeps the region; the city is coarse or broader by setting |
Three checks to run on your own logs this week:
- Count the relay users you turned away. Join the last 30 days of signups, logins and declines against Apple's CSV with the matcher above. How many relay users did your rules decline or challenge?
- Find ASN and hosting blocks. Search your rules for AS13335, AS36183 or
is_hosting. 172.224.226.1 is a relay withis_hosting: true, so a hosting block hits relay users. - Check per-IP limits and city geofences. Apple says to treat relay IPs like carrier-grade NAT, so a per-IP limit sized for one household will throttle many users, and a city-level fence will misfire on a coarse location.
Frequently Asked Questions
How do I know if iCloud Private Relay is on?
On an iPhone, open Settings, tap your name, then iCloud, then Private Relay. As a site operator you cannot see the setting, only the IP: if it matches Apple's egress list, that request came through Private Relay.
Is iCloud Private Relay a VPN?
No. It covers Safari, DNS and insecure http app traffic only, keeps the user in their own country and region, and is limited to validated Apple devices on iCloud+.
Does Cloudflare WARP hide your IP address?
Yes. Cloudflare says WARP replaces the original IP with a Cloudflare IP whether or not the site is on Cloudflare, except for WebRTC traffic and sites given camera or microphone access.
Where is the list of iCloud Private Relay IP addresses?
Apple publishes it at mask-api.icloud.com/egress-ip-ranges.csv. On 2026-10-12 it held 285,091 IPv4 and IPv6 prefixes across 235 country codes.
Can a website see the real IP behind Private Relay?
Not by design. The site sees only the relay IP, and only Apple's first relay sees the user's own address.
Should I block iCloud Private Relay?
No. Apple says IP-based fraud rules may need adjusting for relay users. Allow it, rate-limit it like carrier-grade NAT, and step up only on other risk signals.