An IPv4 address is a 32-bit number written as four bytes, and an IPv6 address is a 128-bit number. Once you treat them as numbers, "is this IP in 10.0.0.0/8?" and "is it in any of these 5.000 ranges?" become integer comparisons. That's how firewalls, allowlists and bot-verification lists work.
The conversions look trivial, and the snippets in most answers have one of two bugs: signed 32-bit overflow in JavaScript, or a broken mask for /0. Everything below was run and checked. The IPv6 conversion was cross-checked against Python's ipaddress on 20.000 random addresses.
IPv4 to integer and back
JavaScript
import { isIP } from 'node:net';
// IPv4 -> unsigned 32-bit integer
export function ipv4ToInt(ip) {
if (isIP(ip) !== 4) throw new TypeError(`not an IPv4 address: ${ip}`);
return ip.split('.').reduce((n, octet) => n * 256 + Number(octet), 0);
}
export function intToIpv4(n) {
return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
}
ipv4ToInt('192.168.1.10'); // 3232235786
ipv4ToInt('255.255.255.255'); // 4294967295
intToIpv4(3232235786); // '192.168.1.10'In browsers and edge runtimes there's no node:net. Validate with the IPv4 regex instead of isIP.
The bug in the common one-liner
// Looks right, isn't:
const broken = (ip) => ip.split('.').reduce((n, o) => (n << 8) + Number(o), 0);
broken('8.8.8.8'); // 134744072 (correct)
broken('192.168.1.10'); // -1062731510 (negative!)JavaScript's bitwise operators work on signed 32-bit integers, so any address from 128.0.0.0 up comes out negative. That's half the address space. Comparisons and database lookups then fail silently. Multiplying by 256 as above stays in normal number arithmetic, which is exact up to 2^53. If you do use bit operations, finish with >>> 0 to reinterpret the result as unsigned.
Python
import ipaddress
int(ipaddress.ip_address("192.168.1.10")) # 3232235786
ipaddress.ip_address(3232235786) # IPv4Address('192.168.1.10')Python integers have no overflow, and ip_address validates the input (rejecting leading zeros since 3.9.5).
IPv6 to integer
128 bits don't fit in a JavaScript number, so use BigInt. The only tricky parts are expanding :: and the embedded-IPv4 form (::ffff:192.0.2.1):
// IPv6 (and IPv4) -> BigInt
export function ipToBigInt(ip) {
const family = isIP(ip);
if (family === 4) return BigInt(ipv4ToInt(ip));
if (family !== 6) throw new TypeError(`not an IP address: ${ip}`);
const [head, tail = ''] = ip.split('::');
const parse = (part) => {
if (!part) return [];
const groups = part.split(':');
const last = groups.at(-1);
if (last.includes('.')) { // embedded IPv4, e.g. ::ffff:192.0.2.1
const v4 = ipv4ToInt(last);
groups.splice(-1, 1, (v4 >>> 16).toString(16), (v4 & 0xffff).toString(16));
}
return groups;
};
const h = parse(head);
const t = parse(tail);
const groups = ip.includes('::') ? [...h, ...Array(8 - h.length - t.length).fill('0'), ...t] : h;
return groups.reduce((n, g) => (n << 16n) + BigInt(parseInt(g, 16)), 0n);
}
ipToBigInt('2001:db8::1'); // 42540766411282592856903984951653826561n
ipToBigInt('::ffff:192.0.2.1'); // 281473902969345nThe isIP check up front matters: it guarantees the string is well-formed, so the splitting can't be fed something like 1::2::3. Zone IDs (fe80::1%eth0) pass isIP and are silently dropped here. Strip or reject them first if they can occur.
In Python it's the same one-liner as IPv4: int(ipaddress.ip_address("2001:db8::1")).
Is an IP inside a CIDR block?
A CIDR block such as 192.168.1.0/24 is all addresses whose first 24 bits equal those of 192.168.1.0. Mask both and compare.
JavaScript, IPv4 only
export function inCidr4(ip, cidr) {
const [base, bitsStr] = cidr.split('/');
const bits = Number(bitsStr);
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return ((ipv4ToInt(ip) & mask) >>> 0) === ((ipv4ToInt(base) & mask) >>> 0);
}
inCidr4('192.168.1.77', '192.168.1.0/24'); // true
inCidr4('172.32.0.1', '172.16.0.0/12'); // false
inCidr4('203.0.113.5', '0.0.0.0/0'); // trueThe bits === 0 special case is the second bug. JavaScript shift counts are taken modulo 32, so ~0 << 32 is ~0 << 0, which is all ones. Without the special case, /0 would mean "exactly this address" instead of "everything".
JavaScript, IPv4 and IPv6
With BigInt there's no overflow or modulo-32 shift, and comparing the shifted prefixes is enough:
export function inCidr(ip, cidr) {
const [base, bitsStr] = cidr.split('/');
const width = isIP(base) === 6 ? 128 : 32;
if (isIP(ip) !== isIP(base)) return false;
const bits = BigInt(bitsStr ?? width);
const shift = BigInt(width) - bits;
return ipToBigInt(ip) >> shift === ipToBigInt(base) >> shift;
}
inCidr('2001:db8:abcd::1', '2001:db8::/32'); // true
inCidr('192.168.1.77', '192.168.1.0/24'); // true
inCidr('::ffff:192.168.1.77', '192.168.1.0/24'); // false: normalize mapped addresses firstNode.js: just use BlockList
Node has had a built-in matcher, net.BlockList, since v15. Despite the name, it's a general address set:
import { BlockList } from 'node:net';
const office = new BlockList();
office.addSubnet('192.168.1.0', 24);
office.addSubnet('2001:db8::', 32, 'ipv6');
office.addAddress('203.0.113.7');
office.check('192.168.1.77'); // true
office.check('2001:db8:abcd::1', 'ipv6'); // true
office.check('::ffff:192.168.1.77', 'ipv6'); // true: BlockList matches IPv4-mapped formsaddSubnet throws on invalid input (ERR_INVALID_ADDRESS, ERR_OUT_OF_RANGE for /40 on IPv4), which is what you want when loading a config file.
Python
import ipaddress
net = ipaddress.ip_network("192.168.1.0/24")
ipaddress.ip_address("192.168.1.77") in net # True
ipaddress.ip_address("2001:db8::1") in net # False (different version, no exception)
ipaddress.ip_network("192.168.1.10/24") # ValueError: 192.168.1.10/24 has host bits set
ipaddress.ip_network("192.168.1.10/24", strict=False) # IPv4Network('192.168.1.0/24')
ipaddress.ip_network("10.1.0.0/16").subnet_of(ipaddress.ip_network("10.0.0.0/8")) # Truestrict=True (the default) is a useful sanity check when reading ranges someone typed by hand. 192.168.1.10/24 is usually a mistake.
First and last address of a block
Firewall UIs, cloud consoles and databases often want a range as "start" and "end" rather than CIDR. Once addresses are integers, a /n block is a run of 2^(32-n) consecutive integers, aligned to a multiple of its size:
export function cidrRange4(cidr) {
const [base, bitsStr] = cidr.split('/');
const size = 2 ** (32 - Number(bitsStr));
const first = Math.floor(ipv4ToInt(base) / size) * size;
return { first: intToIpv4(first), last: intToIpv4(first + size - 1), size };
}
cidrRange4('192.168.1.77/24'); // { first: '192.168.1.0', last: '192.168.1.255', size: 256 }
cidrRange4('0.0.0.0/0'); // { first: '0.0.0.0', last: '255.255.255.255', size: 4294967296 }Division instead of bit masking avoids both JavaScript bugs from above: there's no signed overflow, and /0 works without a special case. In Python, a network object can be indexed: net[0] is the first address, net[-1] the last, and net.num_addresses the size. For IPv4 blocks of /30 and larger, net.hosts() skips the network and broadcast addresses.
Converting a start-end range to CIDR blocks
The reverse problem shows up when a registry, a vendor or a spreadsheet gives you 192.0.2.5 - 192.0.2.20 and your firewall only takes CIDR. An arbitrary range usually needs several blocks:
import ipaddress
list(ipaddress.summarize_address_range(
ipaddress.ip_address("192.0.2.5"), ipaddress.ip_address("192.0.2.20")))
# [IPv4Network('192.0.2.5/32'), IPv4Network('192.0.2.6/31'),
# IPv4Network('192.0.2.8/29'), IPv4Network('192.0.2.16/30'), IPv4Network('192.0.2.20/32')]Sixteen addresses, five blocks. That's normal: a CIDR block must start on a multiple of its own size, so the edges of an unaligned range get split into smaller pieces. Going the other way, collapse_addresses (used in the RangeSet below) merges adjacent and overlapping blocks into the fewest possible.
Sorting IP addresses correctly
Sorting addresses as strings puts 10.0.0.10 before 10.0.0.9, and 2001:db8::1 before ::1. Sort by the numeric value instead, IPv4 first:
const ips = ['10.0.0.10', '10.0.0.9', '2001:db8::1', '192.168.1.1', '::1'];
const key = (ip) => (ip.includes(':') ? 1n << 129n : 0n) + ipToBigInt(ip); // IPv6 after all IPv4
ips.sort((a, b) => { const x = key(a), y = key(b); return x < y ? -1 : x > y ? 1 : 0; });
// ['10.0.0.9', '10.0.0.10', '192.168.1.1', '::1', '2001:db8::1']import ipaddress
ips = ["10.0.0.10", "10.0.0.9", "2001:db8::1", "192.168.1.1", "::1"]
sorted(ips, key=lambda s: (ipaddress.ip_address(s).version, ipaddress.ip_address(s)))
# ['10.0.0.9', '10.0.0.10', '192.168.1.1', '::1', '2001:db8::1']The version comes first in the Python key on purpose: comparing an IPv4Address with an IPv6Address directly raises TypeError, so a plain key=ipaddress.ip_address fails on mixed lists.
Thousands of ranges: sort, merge, binary search
Checking an address against 5.000 ranges one by one is fine for a cron job and wasteful in a request handler. Published bot and cloud lists (Googlebot, GPTBot, cloud provider ranges) run to hundreds or thousands of entries. Convert each range to a [start, end] integer pair, merge overlaps, sort, and binary-search.
Python
import bisect
import ipaddress
class RangeSet:
"""Fast membership test for thousands of CIDR ranges (one IP version)."""
def __init__(self, cidrs):
nets = ipaddress.collapse_addresses(ipaddress.ip_network(c, strict=False) for c in cidrs)
self.starts, self.ends = [], []
for n in nets: # collapse_addresses yields sorted, non-overlapping networks
self.starts.append(int(n.network_address))
self.ends.append(int(n.broadcast_address))
def __contains__(self, ip):
n = int(ipaddress.ip_address(ip))
i = bisect.bisect_right(self.starts, n) - 1
return i >= 0 and n <= self.ends[i]
ranges = RangeSet(["66.249.64.0/27", "66.249.64.32/27", "192.0.2.0/24", "192.0.2.128/25"])
len(ranges.starts) # 2 (adjacent/overlapping ranges merged)
"66.249.64.40" in ranges # True
"66.249.65.1" in ranges # Falsecollapse_addresses needs all networks to be the same version. Keep one RangeSet for IPv4 and one for IPv6.
JavaScript (Node, browsers, Workers, Deno)
// Sorted, merged ranges + binary search: works in Node, browsers, Workers and Deno.
export class RangeSet {
constructor(cidrs) {
const ranges = cidrs.map((cidr) => {
const [base, bitsStr] = cidr.split('/');
const width = base.includes(':') ? 128n : 32n;
const host = width - BigInt(bitsStr ?? width);
const start = (ipToBigInt(base) >> host) << host;
return [start, start + (1n << host) - 1n, width];
});
ranges.sort((a, b) => (a[2] === b[2] ? (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) : a[2] < b[2] ? -1 : 1));
this.ranges = [];
for (const r of ranges) {
const last = this.ranges.at(-1);
if (last && last[2] === r[2] && r[0] <= last[1] + 1n) {
if (r[1] > last[1]) last[1] = r[1];
} else {
this.ranges.push(r);
}
}
}
has(ip) {
const n = ipToBigInt(ip);
const width = ip.includes(':') ? 128n : 32n;
let lo = 0;
let hi = this.ranges.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const [start, end, w] = this.ranges[mid];
if (w < width || (w === width && end < n)) lo = mid + 1;
else if (w > width || start > n) hi = mid - 1;
else return true;
}
return false;
}
}IPv4 and IPv6 ranges live in one sorted array, IPv4 first. Host bits in the input are cleared (192.0.2.10/24 becomes 192.0.2.0/24), matching Python's strict=False. We checked it against BlockList with 500 random IPv4 ranges and 50.000 random addresses, with no disagreements. In edge runtimes, swap ipToBigInt's isIP checks for the regex validator.
IPv6: group by prefix, not by address
With IPv4, one address is a sensible unit for counting, blocking or rate limiting. With IPv6 it isn't. A home or mobile connection typically gets at least a /64, often a /56 or /48. Devices change their address within it regularly through privacy extensions, and anyone with a /64 can use any of 2^64 addresses. Counting per address undercounts real users and can't hold back an abuser.
The usual fix is to key IPv6 traffic on its prefix: /64 for "one connection", /56 or /48 for "one customer". That's the same integer arithmetic as before, followed by turning the integer back into text:
// '2001:db8:1:2:aaaa::1' -> '2001:db8:1:2::/64'
export function ipv6Prefix(ip, bits = 64) {
const host = 128n - BigInt(bits);
const net = (ipToBigInt(ip) >> host) << host;
const groups = net.toString(16).padStart(32, '0').match(/.{4}/g);
const text = groups.map((g) => g.replace(/^0+(?=.)/, '')).join(':');
return `${new URL(`http://[${text}]/`).hostname.slice(1, -1)}/${bits}`; // canonical form
}
ipv6Prefix('2001:db8:1:2:aaaa::1'); // '2001:db8:1:2::/64'
ipv6Prefix('2001:db8:1:2::1', 56); // '2001:db8:1::/56'import ipaddress
def ipv6_prefix(ip, bits=64):
return str(ipaddress.ip_network(f"{ip}/{bits}", strict=False))
ipv6_prefix("2001:db8:1:2:aaaa::1") # '2001:db8:1:2::/64'The URL step produces the canonical RFC 5952 text, so the same prefix always gives the same string, which is what a cache or counter key needs. express-rate-limit does exactly this by default with a /56: see rate limiting by IP. Keep IPv4 addresses whole (or /32). Grouping IPv4 by /24 lumps unrelated customers together, especially behind carrier-grade NAT.
Storing IPs and ranges in a database
- PostgreSQL has native
inetandcidrtypes. Containment is an operator:WHERE ip <<= '10.0.0.0/8'::cidr. A GiST index with theinet_opsoperator class makes it indexable:CREATE INDEX ON ranges USING gist (net inet_ops);. This is the cleanest option by far. - MySQL / MariaDB:
INET_ATON('192.168.1.10')gives the integer,INET6_ATON()gives a binary string for both families (4 bytes for IPv4, 16 for IPv6). Store ranges asstart/endcolumns in the same representation and queryWHERE ? BETWEEN start AND end. Note thatINET_ATONaccepts shorthand such as'127.1', so validate input in the application first. - SQLite or key-value stores: store IPv4 as integers. Store IPv6 as 16-byte big-endian blobs or zero-padded 32-character hex strings, so lexical order equals numeric order.
Where this comes up
- Allowlists for admin panels and webhooks. Combine with a trustworthy client address: see X-Forwarded-For spoofing.
- Trusting proxies and CDNs by their published ranges: see real client IP behind Cloudflare.
- Verifying crawlers against the operators' own IP files: see how to verify AI crawler IP addresses. If you'd rather not maintain the lists, the bot signals on every IPAPI.GR paid plan (
risk.is_ai,risk.is_crawler,risk.is_scanner) tell you whether an address belongs to a known AI crawler, crawler or scanner. - Grouping by network: if you want to know whose network an address is on rather than whether it's in your list, an ASN lookup is the tool.
GET https://ipapi.gr/api/lookup/{ip}?key=…returnsasn,ispandorganization, with"No Data"where none is known.
FAQ
Should I store IPv4 addresses as integers or strings?
Integers (or a native inet type) if you'll ever do range queries, since they sort and compare correctly. Strings are fine for display-only logs, but "10.0.0.9" > "10.0.0.10" as strings, so never range-compare them.
How do I list every address in a CIDR block?
Python: for ip in ipaddress.ip_network("192.0.2.0/29"): ... (or .hosts() to skip the network and broadcast addresses). In JavaScript, loop from the start integer to the end and convert with intToIpv4. Be careful with large blocks: a /8 is 16,7 million addresses.
Why does my IPv4 range not match an IPv4-mapped IPv6 address?
::ffff:192.0.2.1 is technically an IPv6 address. Python and the pure-JS functions above treat it as different from 192.0.2.1. Node's BlockList matches both. Normalize mapped addresses to plain IPv4 at the edge of your system and the problem disappears.