Validating an IP address sounds like a one-line regex. Look at what regex libraries and older answers offer and you'll find patterns that:
- accept
999.999.999.999(\d{1,3}in each position); - accept
1.2.3.4.5orx1.2.3.4y, because the pattern isn't anchored; - accept
010.8.8.8, which some parsers read as octal (8.8.8.8) and others as decimal (10.8.8.8); - reject
2001:db8::1, the normal compressed form of IPv6, because they only handle eight full groups.
This guide gives a strict IPv4 pattern, an IPv6 pattern built from the RFC grammar, both tested against Node's own parser, and the standard-library calls that make a regex unnecessary on the server.
Use the built-in parser when you have one
On a server, a regex is rarely the right tool. Every mainstream runtime has a real parser:
// Node.js
import { isIP, isIPv4, isIPv6 } from 'node:net';
isIP('192.168.1.10'); // 4
isIP('2001:db8::1'); // 6
isIP('999.1.1.1'); // 0 (invalid)
isIP('010.8.8.8'); // 0 (leading zeros rejected)
isIPv4('127.1'); // false (no shorthand forms)# Python 3
import ipaddress
def ip_version(value: str) -> int:
try:
return ipaddress.ip_address(value).version # 4 or 6
except ValueError:
return 0
ip_version("2001:db8::1") # 6
ip_version("010.8.8.8") # 0 (rejected since Python 3.9.5, see below)<?php
// PHP: filter_var returns the address, or false
filter_var('192.168.1.10', FILTER_VALIDATE_IP); // '192.168.1.10'
filter_var('2001:db8::1', FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); // '2001:db8::1'
filter_var('192.168.1.10', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); // false// Go 1.18+
package main
import (
"fmt"
"net/netip"
)
func main() {
for _, s := range []string{"2001:db8::1", "010.0.0.1", "256.1.1.1"} {
addr, err := netip.ParseAddr(s)
if err != nil {
fmt.Println("invalid:", err) // ParseAddr("010.0.0.1"): IPv4 field has octet with leading zero
continue
}
fmt.Println(addr, addr.Is4(), addr.Is6()) // 2001:db8::1 false true
}
}These handle every IPv6 form, reject leading zeros and shorthand like 127.1, and some also parse zone IDs (fe80::1%eth0). Use them whenever the code runs on a server. A regex is useful in the browser (HTML pattern attributes, form libraries), in configuration languages, in database CHECK constraints and in log-processing tools that only speak regex.
Why leading zeros matter
inet_aton, the old C function behind many parsers, reads 010 as octal 8 and accepts shorthand such as 127.1 and 0x7f.0.0.1. Python's ipaddress module used to accept leading zeros and read them as decimal. Two components reading the same string as different addresses is a classic access-control and SSRF bypass: one checks 010.0.0.1 against a blocklist as 10.0.0.1, the other connects to 8.0.0.1.
That was CVE-2021-29921 in Python, fixed in 3.9.5, after which ipaddress rejects leading zeros (bpo-36384). The same class of bug, CVE-2021-28918, hit the npm netmask package. A good validator rejects these forms outright, and so do the patterns below.
A strict IPv4 regex
// IPv4: four decimal octets 0-255, no leading zeros ("010" is rejected).
const octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
const ipv4 = `${octet}(?:\\.${octet}){3}`;
export const IPV4_RE = new RegExp(`^${ipv4}$`);As one literal, if you need to paste it somewhere:
^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$The octet alternatives, in order: 250-255, 200-249, 100-199, then 0-99 without a leading zero. ^ and $ make it reject surrounding text. In JavaScript, $ doesn't match before a trailing newline, so "1.1.1.1\n" is rejected.
An IPv6 regex built from the RFC grammar
IPv6 has one form per position of the :: gap, plus an optional embedded IPv4 at the end (::ffff:192.0.2.1). RFC 3986 spells out the nine cases, and the pattern below writes them out, reusing the IPv4 piece:
// IPv6: the RFC 3986 grammar, one alternative per position of "::".
const h16 = '[0-9a-fA-F]{1,4}';
const ls32 = `(?:${h16}:${h16}|${ipv4})`;
const upTo = (n) => (n === 0 ? '' : `(?:${h16}:){0,${n}}`);
const ipv6 = [
`(?:${h16}:){6}${ls32}`,
`::(?:${h16}:){5}${ls32}`,
`(?:${h16})?::(?:${h16}:){4}${ls32}`,
`(?:${upTo(1)}${h16})?::(?:${h16}:){3}${ls32}`,
`(?:${upTo(2)}${h16})?::(?:${h16}:){2}${ls32}`,
`(?:${upTo(3)}${h16})?::${h16}:${ls32}`,
`(?:${upTo(4)}${h16})?::${ls32}`,
`(?:${upTo(5)}${h16})?::${h16}`,
`(?:${upTo(6)}${h16})?::`,
].join('|');
export const IPV6_RE = new RegExp(`^(?:${ipv6})$`);Building it from named parts keeps it reviewable. The generated pattern is about 1.300 characters. Print IPV6_RE.source if you need it as one string.
How we tested it: we ran both patterns against Node's net.isIP() on a list of tricky cases and 200.000 randomly generated addresses: full, compressed at every position, with embedded IPv4, with too many groups, with :::. The only disagreement was the one we chose: zone IDs such as fe80::1%eth0, which isIP accepts and the regex rejects. Zone IDs only make sense for link-local addresses on the local machine, so rejecting them is usually what a form or API wants.
The same patterns in Python
import re
OCTET = r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
IPV4 = rf"{OCTET}(?:\.{OCTET}){{3}}"
H16 = r"[0-9a-fA-F]{1,4}"
LS32 = rf"(?:{H16}:{H16}|{IPV4})"
def up_to(n):
return rf"(?:{H16}:){{0,{n}}}" if n else ""
IPV6 = "|".join([
rf"(?:{H16}:){{6}}{LS32}",
rf"::(?:{H16}:){{5}}{LS32}",
rf"(?:{H16})?::(?:{H16}:){{4}}{LS32}",
rf"(?:{up_to(1)}{H16})?::(?:{H16}:){{3}}{LS32}",
rf"(?:{up_to(2)}{H16})?::(?:{H16}:){{2}}{LS32}",
rf"(?:{up_to(3)}{H16})?::{H16}:{LS32}",
rf"(?:{up_to(4)}{H16})?::{LS32}",
rf"(?:{up_to(5)}{H16})?::{H16}",
rf"(?:{up_to(6)}{H16})?::",
])
IPV4_RE = re.compile(IPV4, re.ASCII)
IPV6_RE = re.compile(rf"(?:{IPV6})", re.ASCII)
def regex_version(s):
if IPV4_RE.fullmatch(s):
return 4
if IPV6_RE.fullmatch(s):
return 6
return 0Two Python details:
- Use
fullmatch, notmatch.matchonly anchors at the start, and$also matches before a trailing newline. - Use
re.ASCII. In Python 3,\dmatches any Unicode digit. Without the flag,"٣.1.1.1"(an Arabic-Indic three) passes the IPv4 pattern. With it, the pattern agreed withipaddress.ip_address()on 100.000 generated addresses (zone IDs aside).
Test cases worth keeping
| Input | Valid? | Why |
|---|---|---|
0.0.0.0, 255.255.255.255 | IPv4 | boundaries |
256.1.1.1 | no | octet > 255 |
01.2.3.4 | no | leading zero (octal ambiguity) |
127.1, 0x7f.0.0.1 | no | inet_aton shorthand |
1.2.3.4.5, 1.2.3 | no | wrong number of octets |
1.1.1.1, 1.1.1.1\n | no | whitespace: trim first if you mean to accept it |
::, ::1, 1::, 1::8 | IPv6 | compression at every position |
2001:db8:::1, 1:2:3:4:5:6:7:8:9 | no | bad compression / too many groups |
12345:: | no | group longer than 4 hex digits |
::ffff:192.0.2.1 | IPv6 | IPv4-mapped |
::ffff:192.0.2.256 | no | invalid embedded IPv4 |
fe80::1%eth0 | depends | zone ID: parsers accept, our regex rejects |
[::1], [::1]:8080 | no | URL syntax: strip brackets and port first |
10.0.0.0/8 | no | that's a CIDR range, not an address |
Valid isn't the same as public
Often the real question is "is this a public address I can geolocate or allowlist?" Syntax checks don't answer that:
- Python:
ipaddress.ip_address(s).is_globalisFalsefor private, loopback, link-local, documentation and shared (100.64.0.0/10, carrier-grade NAT) ranges. Note that100.64.0.1is neitheris_privatenoris_global. - PHP 8.2+:
FILTER_FLAG_GLOBAL_RANGE. Older versions:FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. - Go: careful.
IsGlobalUnicast()means "not multicast, loopback or link-local". We checked: it returnstruefor192.168.1.10and for2001:db8::1. Combine it with!addr.IsPrivate()and your own list of reserved ranges. - Node.js: there's no built-in flag. Keep a
net.BlockListof the special-purpose ranges (10/8,172.16/12,192.168/16,127/8,100.64/10,169.254/16,::1,fc00::/7,fe80::/10…). The Express client IP guide has a ready-made list.
The authoritative lists of special-purpose ranges are IANA's IPv4 and IPv6 special-purpose address registries.
Normalize before you validate
Real input arrives in shapes the patterns (correctly) reject:
import { isIP, isIPv4 } from 'node:net';
function normalizeIpInput(raw) {
let s = String(raw).trim();
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(s); // [2001:db8::1]:443
if (bracketed) s = bracketed[1];
else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(s)) s = s.slice(0, s.lastIndexOf(':')); // 203.0.113.7:51234
if (s.toLowerCase().startsWith('::ffff:') && isIPv4(s.slice(7))) s = s.slice(7);
return isIP(s) ? s : null;
}Only strip what your input format is documented to contain. For addresses taken from HTTP headers, validate after choosing the trustworthy entry, as described in X-Forwarded-For spoofing.
Finding IPs in logs and text
Validating a whole string and finding addresses inside text are different jobs. ^…$ anchors are wrong for the second, and without them a pattern happily matches 1.2.3.4 inside a version string like 1.2.3.4.5, or 99.1.1.1 inside 999.1.1.1. Word boundaries (\b) don't help, because . is itself a boundary.
Lookarounds that forbid a digit or dot on either side do the job. With GNU grep's PCRE mode:
grep -oP '(?<![0-9.])((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?![0-9.])' access.log \
| sort | uniq -c | sort -rn | headOn a test file containing 203.0.113.7 - - "GET /", foo 999.1.1.1 bar, 10.0.0.1, 192.168.001.1 and version 1.2.3.4.5, it extracted 203.0.113.7 and 10.0.0.1 only. It rejected the out-of-range address, the leading zeros and the version number. The plain \b version of the same pattern wrongly extracted 1.2.3.4 from the version string.
For IPv6 in free text, a regex gets fragile quickly: :: and hex groups also appear in timestamps, MAC addresses and C++ code. The practical approach is a loose candidate pattern (a run of hex digits, colons and dots containing at least two colons), followed by a real parser on each candidate:
import ipaddress
import re
CANDIDATE = re.compile(r"(?<![0-9A-Fa-f:.])[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*(?![0-9A-Fa-f:.])")
def ipv6_in(text):
for m in CANDIDATE.finditer(text):
try:
yield ipaddress.IPv6Address(m.group())
except ValueError:
passThat splits the work sensibly: the regex only has to be generous, and correctness comes from the parser. On a line containing 2001:db8::1, a time (12:34:56), a MAC address and [::1]:443, it returned 2001:db8::1 and ::1 and skipped the rest. Context still matters: run it over C++ and std::vector yields the perfectly valid address d::. Restrict it to the log fields that hold addresses where you can.
Store the canonical form
The same IPv6 address can be written many ways: 2001:0DB8:0000:0000:0000:0000:0000:0001, 2001:db8:0:0:0:0:0:1 and 2001:db8::1 are identical. If you store whatever the user typed or the header contained, deduplication, allowlist lookups and per-IP counters quietly break. RFC 5952 defines one canonical text form: lower case, leading zeros dropped, the longest run of zero groups compressed to :: (the first one on a tie), and :: never used for a single zero group.
import ipaddress
str(ipaddress.ip_address("2001:0DB8:0000:0000:0000:0000:0000:0001")) # '2001:db8::1'
str(ipaddress.ip_address("2001:db8:0:0:1:0:0:1")) # '2001:db8::1:0:0:1'// Node.js, browsers, Deno, Workers: the WHATWG URL parser canonicalizes IPv6 hosts
const canonicalIpv6 = (ip) => new URL(`http://[${ip}]/`).hostname.slice(1, -1);
canonicalIpv6('2001:0DB8:0000:0000:0000:0000:0000:0001'); // '2001:db8::1'Go's netip.Addr.String() also prints the RFC 5952 form. Validate first: the URL trick throws on invalid input and on zone IDs. IPv4 needs no canonicalization once leading zeros are rejected. Normalize IPv4-mapped addresses to plain IPv4 as well, as in the previous section, so that one client never ends up under two keys.
Validating in the browser
On the client, the regex is the right tool: there's no parser in the DOM, and you want instant feedback. The pattern attribute takes the same expression without ^ and $, because browsers anchor it for you:
import { IPV4_RE, IPV6_RE } from './ip-regex.js';
const input = document.querySelector('#ip');
input.addEventListener('input', () => {
const value = input.value.trim();
const ok = value === '' || IPV4_RE.test(value) || IPV6_RE.test(value);
input.setCustomValidity(ok ? '' : 'Enter an IPv4 or IPv6 address, for example 203.0.113.7');
});setCustomValidity hooks into the browser's own validation UI, so screen readers announce the error and the form won't submit until it's fixed. It's a convenience for honest users. Validate again on the server, where it counts.
Before you send an address to an API
Validate locally before calling a lookup API. You can give the user a clear error instead of a generic failure, and you don't spend a request. Our lookup endpoint accepts IPv4, IPv6 and domain names, and anything that's neither an IP nor a domain gets a 400 with "message": "Invalid input":
if (!isIP(ip)) throw new Error('Please enter a valid IPv4 or IPv6 address');
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`);Once you have valid addresses, the next steps are usually converting them to integers and matching CIDR ranges or looking up their country.
FAQ
Should my HTML form use type="text" with pattern?
Yes. There's no type="ip". Put the IPv4 pattern (or the IPv4|IPv6 combination) in the pattern attribute for instant feedback, and validate again on the server with the built-in parser. The pattern attribute is implicitly anchored, so leave out ^ and $ there.
Is an IPv4-mapped IPv6 address the same as the IPv4 address?
It refers to the same IPv4 host, but string comparison treats them as different. Normalize (::ffff:203.0.113.7 to 203.0.113.7) before storing, comparing or rate limiting.
Can I validate CIDR notation with the same regex?
Append /(3[0-2]|[12]?\d) for IPv4 or /(12[0-8]|1[01]\d|[1-9]?\d) for IPv6. Parsing is better: Python's ipaddress.ip_network() also tells you if host bits are set, and Node's BlockList.addSubnet throws on invalid input.