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.
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.
$ 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
| Signal | What it means | Suggested action |
|---|---|---|
is_satellite / ASN 14593 | Satellite 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 IP | Usually the PoP / geofeed city, not the user’s town. | Treat city as approximate. Do not gate content, tax, or shipping on it. |
| Country / region | The geofeed (when present) overrides DB-IP; still not a street-level fix. | Use for language, currency, and legal defaults. |
Wide accuracy_radius_km / low confidence | Location 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 (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 };
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:
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
- IP Geolocation API: country, region, city, accuracy radius and confidence
- What is my IP: see the public IP and city your connection actually presents
- Detect iCloud Private Relay IP without blocking Apple users
- API Docs