DEV Community

Cover image for How Do You Detect Bogon and Reserved IPs in Code?
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

How Do You Detect Bogon and Reserved IPs in Code?

Some IP addresses should never show up as a client: bogon and reserved ranges. A signup form that logs 10.0.0.7 as the visitor, a fraud rule that scores 192.0.2.45, a geolocation lookup on 169.254.10.2: all three are working with addresses that carry no public meaning, and if your code treats them like normal traffic, you get silent bad data.

This is a reference for the application side of that problem. Not router ACLs or BGP prefix lists, which are covered to death elsewhere, but the check you run in Node or PHP when an IP arrives at your app and you have to decide: reject it, flag it, or trust it.

TL;DR

  • Bogon and reserved ranges are IPs that should never appear as a public source: unallocated space plus reserved blocks like private (RFC 1918), CGNAT, loopback, link-local, and documentation.
  • The action depends on the block. Private ranges are fine inside your network but suspect as a public client source. Documentation and "this network" ranges are always rejectable. CGNAT is legitimate but ungeolocatable.
  • The IPGeolocation API returns HTTP 423 Locked for bogon and private addresses, and those lookups do not consume credits, so the API can also act as a validation backstop.
  • In code, classify the IP against the special-use CIDR set locally first (fast, no network), then let the 423 be the authoritative signal on the lookup you were going to make anyway.
  • Normalize IPv4-mapped IPv6 (::ffff:a.b.c.d) before you check, or half your rules miss.

Special-use IPs fall into ranges that are either unallocated or reserved by RFC. Your app should treat each range by what it means: reject the impossible ones, flag the ungeolocatable ones, and accept private ranges only where an internal source makes sense. The rest of this reference is the range-by-range mapping and the code to apply it.

The special-use IP handling matrix

The core of this reference is not "what is a bogon." Our own full canonical IPv4 and IPv6 bogon list already covers the definitions and the complete range tables. What almost nobody writes down is what an application should do with each range when it shows up as a client address. That is the table below.

Three actions:

  • Reject: the address cannot legitimately be a public client. Drop it, or 400 the request.
  • Flag: the address can appear legitimately but you cannot geolocate or trust it. Keep the request, mark it, skip location logic.
  • Accept (internal): valid when the source is genuinely inside your network or behind your own proxy. The same address as a claimed public client source is a reject.

IPv4 special-use ranges and how your app should treat each

Range What it is Defined by App treatment as a public client source
0.0.0.0/8 "This network" RFC 791 Reject
10.0.0.0/8 Private RFC 1918 Accept internal, reject as public
100.64.0.0/10 CGNAT / shared RFC 6598 Flag (behind carrier NAT, ungeolocatable)
127.0.0.0/8 Loopback RFC 1122 Reject (that is your own host)
169.254.0.0/16 Link-local RFC 3927 Reject
172.16.0.0/12 Private RFC 1918 Accept internal, reject as public
192.0.0.0/24 IETF protocol assignments RFC 6890 / IANA registry Check specific assignment; do not blanket-reject the entire /24
192.0.2.0/24 Documentation (TEST-NET-1) RFC 5737 Reject
192.168.0.0/16 Private RFC 1918 Accept internal, reject as public
198.18.0.0/15 Benchmark testing RFC 2544 Reject
198.51.100.0/24 Documentation (TEST-NET-2) RFC 5737 Reject
203.0.113.0/24 Documentation (TEST-NET-3) RFC 5737 Reject
224.0.0.0/4 Multicast RFC 5771 Reject (not a unicast client)
240.0.0.0/4 Reserved, future use RFC 1112 Reject
255.255.255.255/32 Limited broadcast RFC 919 Reject

The authoritative source of truth for this list is the IANA IPv4 Special-Purpose Address Registry, which records each block, its defining RFC, and whether it is valid as a source or destination. If you only hardcode one list, hardcode that one.

IPv6 ranges that actually reach applications

You do not need the full IPv6 bogon set (that is tens of thousands of prefixes). You need the handful that show up at an application:

Range What it is App treatment
::1/128 Loopback Reject (your own host)
::/128 Unspecified Reject
::ffff:0:0/96 IPv4-mapped Normalize, then re-check the embedded IPv4
fc00::/7 Unique local (ULA) Accept internal, reject as public
fe80::/10 Link-local Reject
2001:db8::/32 Documentation Reject
ff00::/8 Multicast Reject

The IPv4-mapped row is the one that bites people. An address like ::ffff:10.0.0.7 is a private IPv4 address wearing an IPv6 coat. If your check only looks at IPv6 ranges, it sails through. Strip the ::ffff: prefix and run the IPv4 rules on what is left.

Bogon, martian, reserved: the distinction developers get wrong

These terms overlap, but they are not identical. A bogon is commonly an address from space that should not appear as a public Internet source, including unallocated space and addresses reserved for private or special use. A martian is an operational term for a packet whose source or destination is invalid or implausible in the network where it appears. Unlike the reserved ranges, “martian” is not a fixed CIDR list; whether an address is martian can depend on routing and network context.

Why it matters for your code: the stable special-use ranges are safe to hardcode because they rarely change. Unallocated address space is different: it changes as IANA and the RIRs delegate space, which is why a static “block all bogons” list eventually goes stale. For application-side validation, check the stable special-use ranges locally and use a current lookup or bogon feed for the moving portion. If you have ever wondered "why do I have a bogon IP in my logs," it is usually one of two things: a misconfigured client leaking a private source, or a spoofed packet. Neither is a real user you can act on.

Why your geolocation lookup returns 423 on these IPs

A well-behaved geolocation API refuses to invent a location for an address that has none. Send a bogon to the lookup endpoint and you get a 423 Locked back instead of a fake record:

curl 'https://api.ipgeolocation.io/v3/ipgeo?apiKey=API_KEY&ip=10.0.0.1'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 423 Locked
{
  "message": "'10.0.0.1' is a bogon IP address."
}
Enter fullscreen mode Exit fullscreen mode

Two things make this useful rather than just an error. First, the lookup is not billed: bogon, private, and malformed IPs do not count against your quota, so you can throw questionable addresses at the API purely to validate them. Second, in a bulk call the same input does not blow up the batch. Invalid entries come back as per-record objects carrying a message field, while the valid IPs in the request are geolocated normally, so you can run a cleanup pass over a dirty log without splitting it first.

IPinfo, ip-api, MaxMind GeoIP2, IPGeolocation, and other IP-data providers handle bogon and private addresses specially, but the response model differs: some return a bogon marker, some return an error, and some return no matching record. I am using IPGeolocation for the examples because the 423 plus the not-billed behavior makes it clean to wire up as a validation layer. Pick whichever is already in your stack; the handling pattern below is the same.

Detecting special-use IPs in code

Two layers. The local check classifies the address against the martian set with no network call, which is the fast path for the common case. The API 423 is the authoritative backstop on the lookup you were making anyway, and it covers the moving unallocated ranges your hardcoded list does not.

Node.js: local classification with net.BlockList

Node ships a CIDR matcher in the standard library, so you do not need a dependency for the local check.

const net = require('node:net');

// Build the block list once at module load. Reuse it; rebuilding per request is wasteful.
const REJECT = new net.BlockList();
const FLAG = new net.BlockList();
const PRIVATE = new net.BlockList();

// Always-reject IPv4 (impossible or documentation-only as a public source)
[['0.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16],
 ['192.0.2.0', 24], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
 ['224.0.0.0', 4], ['240.0.0.0', 4]].forEach(([ip, p]) => REJECT.addSubnet(ip, p, 'ipv4'));

// Private (accept only when an internal source is expected)
[['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16]]
  .forEach(([ip, p]) => PRIVATE.addSubnet(ip, p, 'ipv4'));

// CGNAT: legitimate but ungeolocatable, so flag rather than reject
FLAG.addSubnet('100.64.0.0', 10, 'ipv4');

// IPv6 special-use ranges that can reach application code
REJECT.addSubnet('::', 128, 'ipv6');          // unspecified
REJECT.addSubnet('::1', 128, 'ipv6');         // loopback
REJECT.addSubnet('fe80::', 10, 'ipv6');       // link-local
REJECT.addSubnet('2001:db8::', 32, 'ipv6');   // documentation
REJECT.addSubnet('ff00::', 8, 'ipv6');        // multicast
PRIVATE.addSubnet('fc00::', 7, 'ipv6');       // unique local

function classifyIp(raw) {
  // IPv4-mapped IPv6 (::ffff:10.0.0.7) is a v4 address in disguise. Unwrap before checking.
  const ip = raw.startsWith('::ffff:') && raw.includes('.') ? raw.slice(7) : raw;
  const type = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null;
  if (!type) return 'reject';          // not a valid IP at all
  if (REJECT.check(ip, type)) return 'reject';
  if (FLAG.check(ip, type)) return 'flag';
  if (PRIVATE.check(ip, type)) return 'private';   // caller decides accept vs reject by context
  return 'public';
}

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

net.BlockList does the subnet math for both families, and building the lists once at load keeps the per-request cost to a lookup. The classifyIp return value is the decision your route handler acts on, not a bare boolean, because "reject," "flag," and "internal-only" are three different responses.

For addresses that pass the local special-use checks but may still fall into freshly unallocated space, let the geolocation lookup act as the backstop:

The following example uses the global fetch API and AbortSignal.timeout(), so run it on Node.js 18+.

async function geolocate(ip) {
  const local = classifyIp(ip);
  if (local === 'reject') {
      return { ok: false, reason: 'special-use' };
  }

  if (local === 'flag') {
      return { ok: null, reason: 'cgnat' };
  }

  if (local === 'private') {
      return { ok: null, reason: 'private' };
  }

  try {
    const res = await fetch(
      `https://api.ipgeolocation.io/v3/ipgeo?apiKey=${process.env.IPGEO_API_KEY}&ip=${encodeURIComponent(ip)}`,
      { signal: AbortSignal.timeout(1500) } // never let a lookup hang a request
    );

    // 423 means the API classified it as bogon/private. Trust that over your static list.
    if (res.status === 423) return { ok: false, reason: 'bogon' };
    if (!res.ok) return { ok: null, reason: `http_${res.status}` }; // fail open, see below

    const data = await res.json();
    return { ok: true, location: data.location ?? null };
  } catch (err) {
    // Timeout or network error. Fail open here so a lookup outage does not block real users.
    console.error('geo lookup failed:', err.message);
    return { ok: null, reason: 'lookup_error' };
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the two failure paths. A 423 is a definite "this IP is special-use," so it is safe to act on. A timeout or 5xx is unknown, and the ok: null lets the caller decide whether to fail open or closed.

PHP: a CIDR check without a library

PHP has no native "is this IP in this CIDR" for both families, so here is a compact helper that handles v4 and v6 with inet_pton and a bitmask.

<?php

function ipInCidr(string $ip, string $cidr): bool {
    [$subnet, $bits] = explode('/', $cidr);
    $ipBin = @inet_pton($ip);
    $subnetBin = @inet_pton($subnet);
    // Reject mismatched families (v4 IP against v6 subnet, or an unparseable address)
    if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
        return false;
    }
    $bits = (int) $bits;
    $bytes = intdiv($bits, 8);
    $remainder = $bits % 8;
    // Whole-byte portion must match exactly
    if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($subnetBin, 0, $bytes)) {
        return false;
    }
    // Partial byte: compare only the masked high bits
    if ($remainder !== 0) {
        $mask = ~(0xff >> $remainder) & 0xff;
        if ((ord($ipBin[$bytes]) & $mask) !== (ord($subnetBin[$bytes]) & $mask)) {
            return false;
        }
    }
    return true;
}

function classifyIp(string $raw): string {
    // Normalize IPv4-mapped IPv6 addresses so the IPv4 rules apply.
$ip = $raw;
$packed = @inet_pton($raw);

if (
    $packed !== false &&
    strlen($packed) === 16 &&
    substr($packed, 0, 10) === str_repeat("\x00", 10) &&
    substr($packed, 10, 2) === "\xff\xff"
) {
    $ip = inet_ntop(substr($packed, 12, 4));
}

    if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
        return 'reject'; // not an IP we can trust
    }

    $reject = ['0.0.0.0/8','127.0.0.0/8','169.254.0.0/16','192.0.2.0/24','198.18.0.0/15','198.51.100.0/24','203.0.113.0/24','224.0.0.0/4','240.0.0.0/4','::/128','::1/128','fe80::/10','2001:db8::/32', 'ff00::/8'];
    $private = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'];

    foreach ($reject as $cidr) {
        if (ipInCidr($ip, $cidr)) return 'reject';
    }
    if (ipInCidr($ip, '100.64.0.0/10')) return 'flag'; // CGNAT
    foreach ($private as $cidr) {
        if (ipInCidr($ip, $cidr)) return 'private';
    }
    return 'public';
}
Enter fullscreen mode Exit fullscreen mode

The @ on inet_pton suppresses the warning on a garbage input; the explicit false checks handle it instead. Pair classifyIp with the same API backstop as the Node version. If it returns reject, stop immediately. Handle flag and private according to request context without geolocating them. Only send addresses classified as public to /v3/ipgeo; if that lookup returns HTTP 423, treat it as a bogon or otherwise non-public address that your local static list did not catch.

Reject, flag, or accept: making the call

The local classifier hands you a label; the request context decides the response.

Decision tree for handling a special-use IP: reject, flag, or accept.

Pitfall: The single most common mistake is treating a private source IP as a real client because your app sits behind a load balancer. If you read req.socket.remoteAddress, you will see the proxy, not the user. Read the client IP from a trusted X-Forwarded-For (only the hop your proxy sets, not the whole attacker-controllable header) and classify that.

Fail-open versus fail-closed is a decision you should make on purpose, not by accident. If the geolocation lookup errors or times out, failing open (let the request through, skip the location logic) is right for most apps, because a lookup outage should not lock out real users. Fail closed only when the geolocation is the security control, for example a hard geo-restriction where "unknown" must mean "denied." The 423 case is different from an error: it is a definite answer, so you act on it regardless of your fail direction.

The CGNAT trap deserves a callout. 100.64.0.0/10 is legitimate inside carrier and service-provider networks, but it is not a globally routable client address. If it reaches your application through trusted internal infrastructure, flag it and skip geolocation. If it appears as the claimed source of a normal public Internet request, treat it as invalid or suspicious rather than as a geolocatable public client.

Keeping the list current

The reserved ranges in the tables above are safe to hardcode because they are defined by RFCs that change on the order of years. The unallocated portion is the part that moves, and IPv6 still has large unallocated regions that shift as IANA delegates space. If you need the moving set kept current at the network layer, Team Cymru's bogon reference is the long-standing source, published as BGP feeds, DNS, and plain lists that update through the day. For an application, the cleaner split is to hardcode the static martian set for your local fast path and let the API's 423 cover whatever has changed since you last looked.

If you run this at scale, cache the classification. The local check is cheap, but the API backstop is a network hop, so a short-lived cache keyed on the IP saves you repeat lookups on the same noisy sources, which special-use addresses tend to be.

Top comments (0)