DEV Community

Cover image for Detecting iCloud Private Relay (and Not Treating It Like a VPN)
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

Detecting iCloud Private Relay (and Not Treating It Like a VPN)

Your fraud stack sees a request from an IP it doesn't recognize, tags it anonymous, and quietly bumps the risk score. The user is a paying customer on an iPhone. They never turned on a VPN. They turned on iCloud Private Relay, a privacy feature included with iCloud+ that users can enable on their devices, and your rules just treated them like an attacker.

That misfire is common, and it's getting more common. Microsoft Sentinel users have reported anonymous-IP and anomalous-location alerts involving Private Relay sessions. The fix isn't to block harder. It's to detect relay traffic correctly and treat relay status as a privacy-service signal rather than evidence of malicious intent.

This post shows how to detect iCloud Private Relay and Cloudflare WARP on the server side, in curl and Node.js, and how to wire the result into fraud and geo logic without losing real users.

TL;DR:

  • iCloud Private Relay is not a VPN. It's a dual-hop privacy feature for Safari that preserves the user's country and does not let them fake a location.
  • Detect it server-side by reading is_relay and relay_provider_name from an IP security lookup, or match Apple's published egress ranges yourself.
  • Relay IPs can be shared by multiple users and may change between browsing sessions, so per-IP reputation and city-level geo are unreliable for these sessions.
  • The right handling: keep country-level rules, drop city-level precision, correlate on the account or session, and don't hard-block.
  • Cloudflare WARP is harder to isolate because its egress shares Cloudflare's edge IP space, so lean on a provider label rather than IP ranges.

Relay traffic can look anonymous to a coarse rule, but relay status alone is not evidence of abuse. Private Relay validates supported Apple devices and valid iCloud+ subscriptions, uses shared egress addresses, and preserves a coarse location. Detect it separately from VPN and proxy traffic, then combine the classification with account, device, payment, velocity, and behavioral signals before making a decision.

What Private Relay actually is (and why the IP fools you)

When Private Relay is on, Safari traffic takes two hops. The first hop goes to an Apple-run ingress that can see your real IP but not your destination. The second hop goes to a third-party egress (run by partners like Cloudflare, Akamai, and Fastly) that can see the destination but not your real IP. Your server only ever sees that egress IP.

Two consequences matter for detection. First, Private Relay protects Safari browsing, DNS resolution, and insecure HTTP traffic from apps. It is not a device-wide tunnel for all application traffic. So relay is a privacy-service connection signal, not proof that the user installed a conventional VPN or is attempting to conceal an arbitrary location. Second, the egress preserves coarse location. Per Apple's own description, Private Relay keeps users in their country and general region and does not let them present themselves as connecting from somewhere else. That's the opposite of a VPN's selling point.

The gotcha is that relay egress IPs can be shared by multiple users in a region and may change between browsing sessions, though they stay stable within a session. So two things break: per-IP reputation scoring becomes noise, because multiple unrelated users may share the address, and city-level geolocation is approximate and should not be used for strict location enforcement, because the egress location is approximate by design. Country-level geo still works.

Two ways to detect it

You can detect relay traffic without paying anyone, or you can get it plus a provider label in one API call. Both are legitimate; they trade maintenance for money.

The free path: Apple's egress list

Apple publishes the full set of relay egress ranges as a CSV at https://mask-api.icloud.com/egress-ip-ranges.csv. Each row is a CIDR block with a country, a language/region code, and a city:

# Fetch Apple's published relay egress ranges (CIDR, country, region, city)
curl --max-time 5 -s https://mask-api.icloud.com/egress-ip-ranges.csv | head -n 3
# 172.224.0.0/24,US,US-CA,San Francisco
# 172.225.0.0/24,US,US-NY,New York
# 2a04:4e41::/48,GB,GB-ENG,London
Enter fullscreen mode Exit fullscreen mode

Load those CIDRs into anything that does fast prefix matching and check each incoming IP against them. Any match is Private Relay.

Treat the CSV as a maintained feed rather than a hard-coded lookup table. Refresh it on a schedule, validate each download before replacing the current copy, and retain the last-known-good version if an update fails. A non-match only means that the address is not present in the copy you currently have. The feed covers iCloud Private Relay; it does not classify Cloudflare WARP or other privacy services.

The API path: relay plus geo in one call

The other option is an IP intelligence lookup that returns geolocation and relay classification in the same response. The examples below use ipgeolocation.io's /v3/ipgeo?include=security endpoint, whose security object includes is_relay and relay_provider_name alongside other network-risk fields.

This is only one implementation option. If you only need iCloud Private Relay detection and can maintain the published ranges, Apple's CSV may be sufficient. If you also need classifications for WARP, VPNs, proxies, Tor, and other connection types, use an IP intelligence provider or dataset that fits your existing stack.

Detect relay with code

Here's the raw call. The API key goes in an environment variable, never in the source:

# Request geolocation and security classification in one response
curl --max-time 2 -s -G https://api.ipgeolocation.io/v3/ipgeo \
  --data-urlencode "apiKey=$IPGEO_API_KEY" \
  --data-urlencode "ip=172.225.46.223" \
  --data-urlencode "include=security"
Enter fullscreen mode Exit fullscreen mode

For an iCloud Private Relay egress IP, the security object comes back like this. The location fields (country_code2, city, and so on) are in the same response; I've collapsed them to keep the focus on security:

{
  "ip": "172.225.46.223",
  "location": { "country_code2": "US", "country_name": "United States", "city": "New York" },
  "security": {
    "threat_score": 0,
    "is_tor": false,
    "is_proxy": false,
    "proxy_provider_names": [],
    "proxy_confidence_score": 0,
    "proxy_last_seen": "",
    "is_residential_proxy": false,
    "is_vpn": false,
    "vpn_provider_names": [],
    "vpn_confidence_score": 0,
    "vpn_last_seen": "",
    "is_relay": true,
    "relay_provider_name": "Apple Relay",
    "is_anonymous": true,
    "is_known_attacker": false,
    "is_bot": false,
    "is_spam": false,
    "is_cloud_provider": false,
    "cloud_provider_name": ""
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice is_anonymous is true. That single flag is why naive rules blow up: a "block anonymous IPs" filter treats this validated Apple user exactly like a Tor exit node. The is_relay flag plus relay_provider_name is what lets you tell them apart.

For a verified Cloudflare WARP address, the security object uses the same field structure with a different provider label:

{
  "ip": "104.28.210.10",
  "security": {
    "threat_score": 50,
    "is_relay": true,
    "relay_provider_name": "Cloudflare WARP",
    "is_vpn": false,
    "is_proxy": false,
    "is_anonymous": true,
    "is_cloud_provider": false,
    "cloud_provider_name": ""
  }
}
Enter fullscreen mode Exit fullscreen mode

Treat threat_score as a classification of the specific IP at the time of the lookup, not as a fixed or inherent score for all WARP traffic.

For contrast, here's a real VPN exit (2.56.188.34), which you should treat very differently. is_vpn is true, the provider is named, is_relay is false, and the threat score is high:

{
  "ip": "2.56.188.34",
  "security": {
    "threat_score": 80,
    "is_tor": false,
    "is_proxy": true,
    "proxy_provider_names": ["Zyte Proxy"],
    "proxy_confidence_score": 80,
    "proxy_last_seen": "2025-12-12",
    "is_residential_proxy": true,
    "is_vpn": true,
    "vpn_provider_names": ["Nord VPN"],
    "vpn_confidence_score": 80,
    "vpn_last_seen": "2026-01-19",
    "is_relay": false,
    "relay_provider_name": "",
    "is_anonymous": true,
    "is_known_attacker": true,
    "is_bot": false,
    "is_spam": false,
    "is_cloud_provider": true,
    "cloud_provider_name": "Packethub S.A."
  }
}
Enter fullscreen mode Exit fullscreen mode

All three IPs report is_anonymous: true. Only the specific flags tell you which population you're looking at, and that difference is the whole point.

Now the same lookup in Node.js, wrapped so a slow or failed request never blocks a user. This uses the global fetch in Node 18 and later:

// classifyIp.js - Node 18+ (global fetch). No external deps.
const IPGEO_URL = "https://api.ipgeolocation.io/v3/ipgeo";

// A neutral result used when the lookup fails. Fail open: a geolocation
// outage should never lock out real traffic on its own.
const UNKNOWN = { isRelay: false, relayProvider: null, isVpnLike: false, threatScore: null, ok: false };

async function classifyIp(ip) {
try {  
    const key = process.env.IPGEO_API_KEY; // never hardcode the key
    if (!key) throw new Error("IPGEO_API_KEY is not set");

const url = `${IPGEO_URL}?apiKey=${key}&ip=${encodeURIComponent(ip)}&include=security`;
    // 1.5s ceiling: a request path can't wait on a third party forever.
    const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return UNKNOWN; // 401 on free keys, 429 when rate-limited

    const data = await res.json();
    const s = data?.security ?? {}; // security is absent without include=security

    return {
      isRelay: s.is_relay === true,
      relayProvider: s.relay_provider_name || null, // "Apple Relay" | "Cloudflare WARP"
      isVpnLike: [s.is_vpn, s.is_proxy, s.is_residential_proxy, s.is_tor].some(Boolean),
      isDatacenter: s.is_cloud_provider === true,
      threatScore: typeof s.threat_score === "number" ? s.threat_score : null,
      country: data?.location?.country_code2 ?? null, // still trustworthy for relay
      ok: true,
    };
  } catch {
    // Timeout or network error. Return neutral and let other signals decide.
    return UNKNOWN;
  }
}

module.exports = { classifyIp };
Enter fullscreen mode Exit fullscreen mode

The optional chaining matters because security simply isn't in the response unless you pass include=security, and reaching into an undefined object throws. The AbortSignal.timeout(1500) keeps a third-party hiccup from stalling your request path. And the neutral fallback means the worst case of a lookup failure is "we learned nothing," not "we blocked a customer."

Pitfall: Behind a load balancer, reverse proxy, or CDN, req.socket.remoteAddress usually belongs to the proxy rather than the user. Configure Express trust proxy to trust only your known proxy addresses or network path, then use req.ip. Do not read the leftmost X-Forwarded-For value directly, because clients may be able to spoof it if your proxy chain is not configured correctly.

Handle it correctly (the part everyone skips)

Detection is the easy half. The half that actually protects users is what you do with the result. Relay is a different population from VPN and proxy traffic, so it gets a different path.

Decision flow: if is_relay keep country and drop city; if VPN or datacenter apply stricter risk tiers

// decideRisk.js
// IP classification informs the decision, but should not make it alone.

function decideRisk(c, context = {}) {
  const additionalRisk =
    context.highAccountRisk === true ||
    context.paymentCountryMismatch === true ||
    context.velocityExceeded === true ||
    context.impossibleTravel === true ||
    context.suspiciousBehavior === true;

  // A failed lookup should not block or escalate a user by itself.
  if (!c.ok) {
    return {
      allow: true,
      geoTrust: "unknown",
      escalate: additionalRisk,
      correlateBy: "account-or-session",
      reason: "ip-classification-unavailable",
    };
  }

  // Relay status alone is not suspicious.
  // Escalate only when stronger account or behavioral signals also exist.
  if (c.isRelay) {
  const relayHasAdditionalRisk =
    additionalRisk ||
    (c.threatScore ?? 0) >= 70;

  return {
    allow: true,
    geoTrust: "country",
    escalate: relayHasAdditionalRisk,
    correlateBy: "account-or-session",
    reason: `relay:${c.relayProvider ?? "unknown"}`,
  };
}

  const highNetworkRisk =
    c.isVpnLike ||
    (c.threatScore ?? 0) >= 70 ||
    (
      c.isDatacenter &&
      context.expectedConsumerTraffic === true
    );

  if (highNetworkRisk) {
    return {
      allow: true,
      geoTrust: c.isVpnLike ? "low" : "ip-geolocation",
      escalate: true,
      correlateBy: "account-session-and-ip",
      reason:
        c.isDatacenter && !c.isVpnLike
          ? "datacenter-in-consumer-flow"
          : "vpn-proxy-or-high-risk",
    };
  }

  return {
    allow: true,
    geoTrust: c.country ? "ip-geolocation" : "unknown",
    escalate: additionalRisk,
    correlateBy: "account-session-and-ip",
    reason: additionalRisk
      ? "non-network-risk"
      : "clean",
  };
}

module.exports = { decideRisk };
Enter fullscreen mode Exit fullscreen mode

The policy keeps relay traffic on the normal path unless stronger account, payment, velocity, or behavioral signals justify additional verification. VPN, proxy, and high-risk network signals can trigger stricter treatment. Cloud-provider traffic is escalated only when it appears in a context where consumer traffic is expected, rather than because every cloud-hosted IP is inherently suspicious.

One thing worth saying out loud: don't escalate a relay session just because it's a relay session. Escalate when relay use pairs with something else that's actually suspicious, like a brand-new account, a payment country that doesn't match, signup velocity, or impossible travel. Relay on its own is a Tuesday.

Cloudflare WARP is the harder case

iCloud relay is tidy because Apple publishes the ranges and the second hop is identifiable. WARP is messier. Cloudflare's consumer VPN egresses through the same edge IP space (AS13335) that serves ordinary Cloudflare CDN traffic, and that space also carries iCloud Private Relay's second hop. So the IP and ASN alone can't reliably tell you "this is WARP" the way a CSV match tells you "this is iCloud relay."

That's why the provider label does the work here. A relay_provider_name of "Cloudflare WARP" is a classification from a dataset that has already done the disambiguation, which you can't reproduce from a public IP list. If you're rolling your own detection, WARP is the case where you'll want a feed or API rather than a static file.

Handle WARP as a separate connection category. Do not block a request solely because it uses WARP, but do not apply Apple-specific assumptions about validated iCloud+ subscriptions, Apple devices, or Safari traffic. WARP is a device-level tunnel that replaces the original IP with a Cloudflare address representing the user's approximate location. Combine the WARP classification with account, device, payment, velocity, and behavioral signals before choosing a normal, step-up, review, or block path.

A few extra notes

Cache successful classifications for a bounded period so the application does not repeat the same external lookup on every request. Use a shorter TTL for failed or unknown results, and choose the duration according to how much stale classification data your application can tolerate.

Remember the scope: Private Relay protects Safari web browsing, DNS resolution queries, and insecure HTTP traffic from apps. It is not a device-wide tunnel for all native app traffic, so secure app connections and certain excluded traffic may bypass it. For deeper background on how operators should treat relay sessions, see Cloudflare's operator guidance and Apple's WWDC session for developers.

Place classifyIp and decideRisk ahead of your existing fraud rules, then treat relay status as context rather than a verdict. Do not collapse Private Relay, WARP, VPNs, proxies, Tor, and cloud-hosted traffic into one anonymous-IP category. They represent different connection types, and the final decision should combine network classification with account, device, payment, velocity, and behavioral signals.

Top comments (0)