Why Starlink IPs Geolocate to the Wrong City (and How to Check)

Starlink CGNAT and ground-station egress make generic IP databases pin users to a PoP city — sometimes the wrong country. Here is how to detect satellite traffic and treat that city as approximate.

Sign up Read the docs

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

Why the city is the PoP, not the dish

A Starlink terminal does not present a unique public IP at the user’s house. Customer traffic is CGNAT’d and leaves the network at a ground station / point of presence (PoP). The address your origin sees belongs to that egress, not to the dish.

Generic IP geolocation databases (including DB-IP City Lite, which is the base layer behind Network Data Labs) map prefixes to a registered or observed location. For Starlink that is often the PoP city, a nearby hub, or even the wrong country when the feed is stale or the prefix moved. A user in a rural region can therefore show up in a coastal metro. Check what is my IP from a Starlink connection: the city you see is the network’s egress, not GPS on the roof.

Starlink IP geolocation accuracy at city level is a coarse estimate. Country and region are the levels you can usually act on; city is a hint.

What an RFC 8805 geofeed is

An RFC 8805 geofeed is a CSV the network operator publishes: prefix, country, region, city (and sometimes postal). It is the ISP’s own statement of where those prefixes should be located — not a GPS fix for each subscriber.

Starlink publishes one at https://geoip.starlinkisp.net/feed.csv. The rows describe customer prefixes (typically the PoP / service area), so a geofeed city can still be hundreds of kilometers from the dish. It is still a better source than a generic database that never heard the prefix moved.

The same RFC 8805 format is how Apple publishes iCloud Private Relay egress. Relays and satellite are different problems; both are cases where the IP’s “city” is not the person.

How Network Data Labs applies geofeeds

Lookups start from DB-IP. When an RFC 8805 geofeed matches the IP, that geofeed overrides DB-IP for country, and for region/city when the feed provides them. Match is longest-prefix. Starlink’s feed is one of several ISP geofeeds (alongside a consolidated feed and official provider feeds).

For Starlink’s customer network, network.asn 14593, the Starlink geofeed wins over other geofeed sources of equal prefix length. A more-specific prefix from any source still wins. We do not put the geofeed source name in the public JSON, and we do not claim a city-accuracy percentage: the feed can still name a PoP city, and GeoNames is used to attach coordinates when the city resolves.

Detect satellite with one lookup

Geolocation, ASN and traits.is_satellite are on the Free plan: 1,000 requests per UTC calendar month, 1 request/second, personal and non-commercial use. Risk flags (is_vpn, is_tor, is_relay, is_hosting) are paid-only.

Call GET https://api.networkdatalabs.com/v0/ip/{ip} with Authorization: Bearer. The fields that matter here:

  • traits.is_satellite — true when the IP’s ASN is on the satellite list. Starlink customer traffic is ASN 14593.
  • network.asn — confirm 14593 (SpaceX Starlink customer network).
  • location.accuracy_radius_km — estimated radius in kilometers. Always present when geo is returned; a wide radius means city is a best-effort guess.
  • location.confidence — high | medium | low | unknown.

See the IP geolocation API for the full location object.

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}, network: {asn: .network.asn}, traits: {is_satellite: .traits.is_satellite}}'

{
  "ip": "<Starlink client IP>",
  "location": {
    "country_code": "US",
    "region": "Washington",
    "city": "Seattle",
    "accuracy_radius_km": 50.0,
    "confidence": "medium"
  },
  "network": {
    "asn": 14593
  },
  "traits": {
    "is_satellite": true
  }
}

The JSON above is the shape of a lookup when the IP is on Starlink (ASN 14593) and a geofeed city resolved. 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). The city, when present, is the feed/PoP city, not the dish. Always read accuracy_radius_km and confidence before you use it. There is no Starlink-specific trait: the public flag is is_satellite.

Recommended policy

SignalWhat it meansSuggested action
is_satellite / ASN 14593Satellite internet (Starlink customer network). Ordinary households, rural sites, maritime and in-motion terminals.Allow. Do not hard-block. Log it so you can treat geo as coarse.
City on a satellite IPUsually the PoP / geofeed city, not the user’s town.Treat city as approximate. Do not gate content, tax, or shipping on it.
Country / regionThe geofeed (when present) overrides DB-IP; still not a street-level fix.Use for language, currency, and legal defaults.
Wide accuracy_radius_km / low confidenceLocation is coarse (typical when only a region or country is known).Skip city. If you need a precise place (delivery, local tax, “near me”), ask the user.

Node

Look up the IP, flag satellite, and keep city off any hard rule. Fail open so a slow lookup never 500s the request:

satellite-geo.js
// satellite-geo.js (Node 18+, global fetch)
const API = "https://api.networkdatalabs.com/v0/ip/";
const KEY = process.env.NDL_API_KEY;
const STARLINK_ASN = 14593;
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 geoPolicy(data) {
  const t = data?.traits || {};
  const loc = data?.location || {};
  const net = data?.network || {};
  const satellite = t.is_satellite === true || net.asn === STARLINK_ASN;
  const coarse =
    satellite ||
    loc.confidence === "low" ||
    (loc.accuracy_radius_km ?? 0) >= 200;
  return {
    action: "allow",
    satellite,
    useCity: !coarse,
    country: loc.country_code,
    region: loc.region,
    city: loc.city,
    accuracy_radius_km: loc.accuracy_radius_km,
    confidence: loc.confidence,
  };
}

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

module.exports = { ipGeo };
app.js
const express = require("express");
const { ipGeo } = require("./satellite-geo");

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

app.get("/catalog", (req, res) => {
  const geo = req.ipGeo;
  // Country/region for catalog defaults. Never 403 a Starlink user
  // because city !== expected city.
  res.json({
    country: geo.country,
    region: geo.region,
    city: geo.useCity ? geo.city : null,
    askUserLocation: geo.satellite || !geo.useCity,
  });
});

Python

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

satellite_geo.py
import os
import httpx

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

def geo_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 {}
    net = data.get("network") or {}
    satellite = t.get("is_satellite") is True or net.get("asn") == STARLINK_ASN
    coarse = satellite or loc.get("confidence") == "low" or (loc.get("accuracy_radius_km") or 0) >= 200
    return {
        "action": "allow",
        "satellite": satellite,
        "use_city": not coarse,
        "country": loc.get("country_code"),
        "region": loc.get("region"),
        "city": loc.get("city"),
        "accuracy_radius_km": loc.get("accuracy_radius_km"),
        "confidence": loc.get("confidence"),
    }

FAQ

Can I just block ASN 14593?

No. That ASN is Starlink’s customer network — real households and businesses. Hard-blocking it drops people whose only path to the internet is satellite. Detect it, treat geo as coarse, and ask for a location when you truly need one.

Does is_satellite mean the city is wrong?

It means the IP is on a satellite ASN, so the city is at best the PoP / geofeed city. Read accuracy_radius_km and confidence; do not assume city equals the user’s town.

Is this on the Free plan?

Yes for geolocation, ASN and is_satellite. Free is 1,000 requests per UTC calendar month, 1 request/second, personal and non-commercial use. Commercial use requires a paid plan. See the IP geolocation API.

Related

Sign up Read the docs