IP address validation: a correct IPv4 and IPv6 regex, and when to use the built-ins

Regex libraries are full of IP patterns that accept 999.1.1.1 or only handle full-length IPv6. Here is a pattern that matches what Node's own parser accepts across 200.000 generated addresses, plus the one-line built-ins you should usually use instead.

On this page Use the built-in parser when you have one

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.5 or x1.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:

js
// 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
# 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
// 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
// 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

js
// 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:

text
^(?: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:

js
// 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

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 0

Two Python details:

  • Use fullmatch, not match. match only anchors at the start, and $ also matches before a trailing newline.
  • Use re.ASCII. In Python 3, \d matches any Unicode digit. Without the flag, "٣.1.1.1" (an Arabic-Indic three) passes the IPv4 pattern. With it, the pattern agreed with ipaddress.ip_address() on 100.000 generated addresses (zone IDs aside).

Test cases worth keeping

InputValid?Why
0.0.0.0, 255.255.255.255IPv4boundaries
256.1.1.1nooctet > 255
01.2.3.4noleading zero (octal ambiguity)
127.1, 0x7f.0.0.1noinet_aton shorthand
1.2.3.4.5, 1.2.3nowrong number of octets
1.1.1.1, 1.1.1.1\nnowhitespace: trim first if you mean to accept it
::, ::1, 1::, 1::8IPv6compression at every position
2001:db8:::1, 1:2:3:4:5:6:7:8:9nobad compression / too many groups
12345::nogroup longer than 4 hex digits
::ffff:192.0.2.1IPv6IPv4-mapped
::ffff:192.0.2.256noinvalid embedded IPv4
fe80::1%eth0dependszone ID: parsers accept, our regex rejects
[::1], [::1]:8080noURL syntax: strip brackets and port first
10.0.0.0/8nothat'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_global is False for private, loopback, link-local, documentation and shared (100.64.0.0/10, carrier-grade NAT) ranges. Note that 100.64.0.1 is neither is_private nor is_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 returns true for 192.168.1.10 and for 2001: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.BlockList of 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:

js
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:

bash
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 | head

On 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:

python
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:
            pass

That 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.

python
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'
js
// 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:

js
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":

js
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.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.