How to Detect iCloud Private Relay Traffic (Without Blocking Apple Users)

Identify Apple’s two-hop privacy relay from the egress IP, keep it separate from VPNs, and avoid locking out real Safari and iCloud+ users.

Sign up Read the docs

Free: 1,000 req/month · HTTPS · personal & non-commercial use

What iCloud Private Relay is

iCloud Private Relay is Apple’s browsing-privacy feature (part of iCloud+). When it is on, Safari does not connect to your origin from the user’s home or cellular IP. Traffic takes two hops:

  • Ingress sees the user’s IP, but not the site they are visiting.
  • Egress sees the destination, but not the user’s IP. Your server only ever sees this second hop.

Apple publishes the egress addresses as an RFC 8805 geolocation feed (prefix, country, region, city) so operators can recognize the traffic. The list is at https://mask-api.icloud.com/egress-ip-ranges.csv. Network Data Labs maps those prefixes to traits.is_relay.

It is not a VPN — and blocking it hurts real users

Private Relay is not a general-purpose VPN. It does not tunnel every app on the device, it does not let the user pick an exit country, and it is not designed to hide identity for fraud. It is a privacy path for Safari (and some other Apple internet features) that hides the client IP from the site.

The people behind those egress IPs are ordinary iPhone, iPad and Mac users with iCloud+. If you treat the IP like a datacenter VPN and hard-block it, you drop real customers in Safari. Check what is my IP from a device with Private Relay on: the address you see is the egress, not the home router.

Keep the signal, change the policy: allow relay by default, and only add friction on high-risk actions (payment, password reset, payout) if you also have another reason to step up.

Geolocation for relay egress is region-level

Apple assigns egress in the user’s approximate region so sites can still do coarse localization (country / region, language, currency) without seeing the home IP. The city, when present, is a best-effort guess. Do not treat coordinates as a street-level fix.

Every Network Data Labs geo response includes location.accuracy_radius_km (estimated radius in kilometers) and location.confidence (high | medium | low | unknown). For relay traffic, expect a wide radius and lower confidence — often region-scale (on the order of a couple of hundred kilometers when only a region is known). Use country and region for defaults; skip city when the radius is wide or confidence is low.

Detect it with one lookup

Risk flags (is_relay, is_vpn, is_tor, is_hosting) are on paid plans. Free keys return geolocation + ASN only, 1,000 requests per UTC calendar month, 1 request/second, for personal and non-commercial use.

Call GET https://api.networkdatalabs.com/v0/ip/{ip} with Authorization: Bearer. The fields that matter here are traits.is_relay, traits.is_vpn, traits.is_tor, location.accuracy_radius_km and location.confidence. A relay egress is reported as is_relay: true and is_vpn: false — the same IP is never also flagged as a VPN.

GET /v0/ip/{ip}
$ curl -s https://api.networkdatalabs.com/v0/ip/$CLIENT_IP \
    -H "Authorization: Bearer YOUR_API_KEY" \
    | jq '{ip, location: {country_code: .location.country_code, region: .location.region, city: .location.city, accuracy_radius_km: .location.accuracy_radius_km, confidence: .location.confidence}, traits: {is_relay: .traits.is_relay, is_vpn: .traits.is_vpn, is_tor: .traits.is_tor}}'

{
  "ip": "<visitor egress IP>",
  "location": {
    "country_code": "US",
    "region": "California",
    "city": null,
    "accuracy_radius_km": 200.0,
    "confidence": "low"
  },
  "traits": {
    "is_relay": true,
    "is_vpn": false,
    "is_tor": false
  }
}

The JSON above is the shape of a paid-plan lookup when the IP is a privacy-relay egress with region-level geo. Swap in the visitor IP you actually see (the one in req.ip, CF-Connecting-IP, or your load balancer’s client address — see what is my IP). City may be present or null; always read accuracy_radius_km and confidence before you use it. Egress often sits on a CDN or hosting network, so is_hosting can also be true — still treat is_relay as the Apple-user signal, not as a VPN.

Recommended policy

SignalWhat it meansSuggested action
is_relayPrivacy relay egress (iCloud Private Relay and similar). Ordinary Safari / iCloud+ users.Allow. Log it. Step up only on high-risk actions if you have another reason.
is_vpnKnown VPN network, including major commercial VPN providers. Never set together with is_relay.Allow with a flag; extra verification on payment or payout.
is_torCurrent Tor exit node.Treat as higher risk than relay or VPN: verify or review, don’t copy the relay policy.
Wide accuracy_radius_km / low confidenceLocation is coarse (typical for relay egress).Use country/region only; do not gate on city.

Express middleware

Trust one proxy hop, look up the IP, and attach a decision. Fail open so a slow lookup never 500s the request:

relay.js
// relay.js (Node 18+, global fetch)
const API = "https://api.networkdatalabs.com/v0/ip/";
const KEY = process.env.NDL_API_KEY;
const cache = new Map();
const TTL_MS = 6 * 60 * 60 * 1000;

function normalize(ip) {
  return ip && ip.startsWith("::ffff:") ? ip.slice(7) : ip;
}

async function lookup(ip) {
  const hit = cache.get(ip);
  if (hit && hit.exp > Date.now()) return hit.data;
  const res = await fetch(API + encodeURIComponent(ip), {
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(800),
  });
  if (!res.ok) throw new Error(`lookup ${res.status}`);
  const data = await res.json();
  cache.set(ip, { data, exp: Date.now() + TTL_MS });
  return data;
}

function policy(data) {
  const t = data?.traits || {};
  const loc = data?.location || {};
  if (t.is_tor) return { action: "review", reason: "tor_exit" };
  if (t.is_relay) {
    return {
      action: "allow",
      reason: "privacy_relay",
      coarseGeo: loc.confidence === "low" || (loc.accuracy_radius_km ?? 0) >= 200,
    };
  }
  if (t.is_vpn) return { action: "step_up", reason: "known_vpn_network" };
  return { action: "allow", reason: null };
}

function ipPolicy() {
  return async (req, res, next) => {
    try {
      const data = await lookup(normalize(req.ip));
      req.ipPolicy = policy(data);
      req.geo = data.location;
    } catch (err) {
      req.ipPolicy = { action: "allow", reason: "lookup_unavailable" };
    }
    next();
  };
}

module.exports = { ipPolicy };
app.js
const express = require("express");
const { ipPolicy } = require("./relay");

const app = express();
app.set("trust proxy", 1);   // one reverse proxy in front of Node
app.use(ipPolicy());

app.post("/checkout", (req, res) => {
  const { action, reason } = req.ipPolicy;
  if (action === "review") return res.status(403).json({ error: "review_required" });
  if (action === "step_up") return res.status(401).json({ error: "step_up", reason });
  // is_relay lands here: allow. Optionally require 3DS / extra auth only
  // when this is a high-risk payout *and* you have another signal.
  res.json({ ok: true });
});

Python

Same policy in a small helper you can call from FastAPI or Flask. Keep the key in NDL_API_KEY on the server:

relay.py
import os
import httpx

API = "https://api.networkdatalabs.com/v0/ip/"
HEADERS = {"Authorization": f"Bearer {os.environ['NDL_API_KEY']}"}

def policy_for_ip(ip: str) -> dict:
    try:
        r = httpx.get(API + ip, headers=HEADERS, timeout=1.0)
        r.raise_for_status()
        data = r.json()
    except httpx.HTTPError:
        return {"action": "allow", "reason": "lookup_unavailable"}

    t = data.get("traits") or {}
    loc = data.get("location") or {}
    if t.get("is_tor"):
        return {"action": "review", "reason": "tor_exit"}
    if t.get("is_relay"):
        coarse = loc.get("confidence") == "low" or (loc.get("accuracy_radius_km") or 0) >= 200
        return {"action": "allow", "reason": "privacy_relay", "coarse_geo": coarse}
    if t.get("is_vpn"):
        return {"action": "step_up", "reason": "known_vpn_network"}
    return {"action": "allow", "reason": None}

FAQ

Can I just block Apple’s published CSV myself?

You can fetch the same RFC 8805 list, but blocking those prefixes is how sites accidentally lock out Safari. Detect the range, then allow it. Using the API keeps is_relay separate from is_vpn so you do not inherit a VPN blocklist that also contains relay egress.

Does this work on IPv6?

Yes. Private Relay uses IPv4 and IPv6 egress. GET /v0/ip/{ip} accepts both.

Is is_relay on the Free plan?

No. Free includes 1,000 requests per UTC calendar month with geolocation + ASN, 1 request/second, for personal and non-commercial use. Risk flags and commercial use are on paid plans. See VPN detection API for the full trait list.

Related

Sign up Read the docs