ping6.net
Best Practices

IPv6 for Web Developers

What web developers need to know about IPv6: URLs with brackets, database storage, socket programming, and common bugs to avoid.

ping6.netDecember 14, 20246 min read
IPv6web developmentprogrammingdatabasessockets

If you've been ignoring IPv6 because "it'll be a problem for later," later is now. Mobile networks run IPv6-first. Some users behind NAT64 can only reach you via IPv6. Even localhost on your development machine probably resolves to ::1 before 127.0.0.1.

This guide covers what you actually need to know: handling IPv6 in URLs, storing addresses in databases, socket programming, and the bugs that will bite you if you're not careful.

IPv6 in URLs#

IPv6 addresses contain colons, which conflicts with the port separator in URLs. The solution is square bracket notation:

http://[2001:db8::1]:8080/api/users
https://[2606:2800:220:1:248:1893:25c8:1946]/

Without brackets, parsers can't tell where the address ends and the port begins. This applies to:

  • HTTP/HTTPS URLs
  • WebSocket connections (ws://[::1]:3000)
  • Database connection strings
  • Any URI scheme

When generating URLs programmatically, wrap IPv6 addresses in brackets. When parsing, strip them before validation or storage.


Database Storage#

The classic mistake is using VARCHAR(15) for IP addresses. That fits IPv4 (maximum 15 characters: 255.255.255.255) but not IPv6.

PostgreSQL has a native INET type that handles both IPv4 and IPv6:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  ip_address INET NOT NULL
);
 
-- Automatic validation and normalization
INSERT INTO users (ip_address) VALUES ('2001:db8::1');
INSERT INTO users (ip_address) VALUES ('192.0.2.1');

The INET type stores addresses efficiently, validates them on insert, and supports network operations like subnet matching with << and >> operators.

MySQL lacks a native type, so use either:

  • VARBINARY(16) - store the binary representation (16 bytes for IPv6, 4 for IPv4)
  • VARCHAR(45) - store the string representation (max length of expanded IPv6)

Binary storage is more efficient but requires conversion functions:

-- Store
INSERT INTO users (ip_address) VALUES (INET6_ATON('2001:db8::1'));
 
-- Retrieve
SELECT INET6_NTOA(ip_address) FROM users;

Address Normalization

IPv6 addresses have multiple valid representations. Always normalize before comparing or indexing to ensure consistency.

Normalization matters. IPv6 addresses have multiple valid representations:

  • 2001:0db8:0000:0000:0000:0000:0000:0001
  • 2001:db8::1 (compressed)
  • 2001:db8:0:0:0:0:0:1 (partially compressed)

Always normalize before comparing or indexing. Most libraries have a canonical form function.


Socket Programming#

The socket API treats IPv4 and IPv6 as separate address families. Modern applications should support both.

Node.js defaults to dual-stack sockets:

const http = require('http');
 
// Binds to :: (all IPv6 addresses) and accepts IPv4 via mapping
const server = http.createServer((req, res) => {
  res.end(`Your IP: ${req.socket.remoteAddress}`);
});
 
server.listen(3000, '::');

The :: address is the IPv6 equivalent of 0.0.0.0, accepting connections on all interfaces. Most systems support IPv4-mapped IPv6 addresses (::ffff:192.0.2.1), allowing a single IPv6 socket to handle both protocols.

Python requires explicit dual-stack handling:

import socket
 
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
sock.bind(('::', 8080))
sock.listen(5)

The IPV6_V6ONLY flag controls whether the socket accepts only IPv6 or both IPv4 and IPv6.

Go makes dual-stack the default:

listener, err := net.Listen("tcp", ":8080")
// Automatically listens on both IPv4 and IPv6

For client connections, always use getaddrinfo() (or your language's equivalent) instead of manually resolving addresses. It handles IPv4, IPv6, and dual-stack scenarios correctly:

import socket
 
# Don't do this
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('example.com', 80))

This hardcodes IPv4 and fails on IPv6-only networks.

# Do this - works for both IPv4 and IPv6
addr_info = socket.getaddrinfo('example.com', 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
sock = socket.socket(addr_info[0][0], addr_info[0][1])
sock.connect(addr_info[0][4])

Local Testing#

Most systems resolve localhost to both ::1 (IPv6) and 127.0.0.1 (IPv4). Check your /etc/hosts:

127.0.0.1       localhost
::1             localhost

If your application binds only to 127.0.0.1, it won't accept IPv6 connections. Bind to :: for dual-stack or explicitly bind to both addresses.

To test IPv6-only behavior, disable IPv4 on your loopback interface or use [::1] explicitly in your HTTP client:

curl http://[::1]:3000/

For realistic testing, use an IPv6-only test network. Many cloud providers offer IPv6-only instances that force you to handle edge cases like NAT64/DNS64.


CDN and Origin Servers#

Most CDNs (Cloudflare, Fastly, AWS CloudFront) support IPv6 by default. They'll serve content over IPv6 to clients and translate to IPv4 for your origin if needed.

The catch: if you expose APIs directly (bypassing the CDN), your origin server must support IPv6. Check your DNS has AAAA records and your firewall allows IPv6 traffic.


Common Bugs#

Regex validation. IPv6 regex patterns are notoriously complex and usually wrong:

// Don't do this
const ipv6Regex = /^([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}$/i;
// Fails on :: compression, IPv4-mapped addresses, zone IDs...

Use a proper parsing library instead. Every major language has one that handles IPv6 correctly. For validation, use our IPv6 Validator tool.

Hardcoded IPv4 addresses. Search your codebase for patterns like:

const API_SERVER = 'http://192.168.1.100:3000';  // Won't work with IPv6

Use hostnames instead, or support both address families.

IP-based rate limiting. IPv6 addresses change frequently due to privacy extensions. Rate limiting by exact address will fail. Limit by /64 subnet instead:

// Bad
const key = `rate:${ipAddress}`;
 
// Better for IPv6
const key = `rate:${ipv6ToSubnet64(ipAddress)}`;

Privacy Extensions Impact

IPv6 privacy extensions change addresses frequently, breaking exact-match rate limiting. Always use subnet-based limits for IPv6.

Libraries without IPv6 support. Older HTTP clients, database drivers, and networking libraries may not handle IPv6. Test with actual IPv6 addresses, not just localhost. If a library fails, check for updates or alternatives.

URL parsing edge cases. Ensure your router/framework handles bracketed IPv6 addresses:

GET http://[2001:db8::1]:8080/api/users
Host: [2001:db8::1]:8080

Some parsers incorrectly include the brackets in the Host header or fail to extract the address correctly.


Checklist#

Your application is IPv6-ready when:

  • Database columns can store 45-character strings or use native IP types
  • Servers bind to :: or explicitly listen on both IPv4 and IPv6
  • Client code uses getaddrinfo() or equivalent for name resolution
  • IP validation doesn't rely on regex
  • No hardcoded IPv4 addresses in configuration
  • Rate limiting and geolocation handle IPv6 subnets
  • URL parsing handles bracketed notation

IPv6 isn't optional anymore. Build it in from the start, and you'll avoid the painful migration later.

Validate IPv6 Addresses

Use our IPv6 Validator and Ping Tool to test your application's IPv6 support.