IPnatnapttranslation tableprivate addresseshole punching

NAT: Many Private Hosts Behind One Public Address

A NAT rewrites the private source 10.0.0.4:54001 to the public 203.0.113.7:62014 on the way out, records the mapping in a table, and reverses it on the way back — which lets a household or a data centre share one address, and which is the reason inbound connections, peer-to-peer, long-lived idle sockets and high connection rates all need special handling.

IPv4
▶ InteractiveInterview question
Progress

The problem

Your laptop is 10.0.0.4, an address ten million other laptops also have. A reply from engineer-atlas.dev addressed to 10.0.0.4 cannot be routed anywhere. How does a packet from a private address get an answer — and what does the trick cost?

The rewrite

Your home router has two addresses: 10.0.0.1 on the LAN and one public address on the ISP side, say 203.0.113.7. When it forwards your packet from 10.0.0.4:54001 to 198.51.100.10:443, it rewrites the source to 203.0.113.7:62014 — its own public IP and a port it picks from a free pool — recomputes the IP and TCP checksums, and records the mapping: 10.0.0.4:54001 ↔ 203.0.113.7:62014 for destination 198.51.100.10:443. The server sees a connection from 203.0.113.7:62014 and replies to it. The router receives the reply, finds the mapping by its destination port 62014, rewrites the destination back to 10.0.0.4:54001, and delivers it on the LAN. Neither end knows.

Strictly this is NAPT (network address *and port* translation) or "masquerading"; plain one-to-one NAT rewrites addresses only. The port is the key: rewriting only addresses would let one public address front one private host, and rewriting ports lets it front thousands, because the 16-bit port becomes the demultiplexing key the address no longer provides. Every consumer router, every cloud NAT gateway, and Docker’s default bridge do this. Cloud "NAT gateways" and iptables -t nat -A POSTROUTING -j MASQUERADE are the same mechanism at different scales.

Two addresses, one hop
src 10.0.0.4:54001src 203.0.113.7:62014Laptop 10.0.0.4:54001Router / NAT 10.0.0.1 | 203.0.113.7InternetServer 198.51.100.10:443
UserLLMAgentToolDataDecisionHumanGuardrail
A NAT translation table (educational model)
inside (private)     outside (public)     destination           proto  state        idle
10.0.0.4:54001   ↔   203.0.113.7:62014    198.51.100.10:443     tcp    ESTABLISHED  2s
10.0.0.4:54002   ↔   203.0.113.7:62015    198.51.100.10:443     tcp    ESTABLISHED  41s
10.0.0.9:41000   ↔   203.0.113.7:62016    192.0.2.53:53         udp    REPLIED      8s
10.0.0.4:5060    ↔   203.0.113.7:62017    192.0.2.77:5060       udp    UNREPLIED    29s   ← expires at 30s

outbound: match (inside src, dst) → rewrite src to outside
inbound:  match (outside dst port, src) → rewrite dst to inside

Kinds of NAT: who may send to the mapping

The mapping was created by an *outbound* packet. The question that decides everything else is: which inbound packets may use it? A full-cone NAT lets any external host send to 203.0.113.7:62014 and reach 10.0.0.4:54001 — permissive, rare. An address-restricted NAT accepts only from the IP the inside host sent to. A port-restricted NAT — the common home router — accepts only from the exact IP *and port* the inside host sent to. A symmetric NAT goes further: it allocates a *different* external port for each destination, so a mapping learned by talking to server A says nothing about the port used for server B. Carrier-grade and many enterprise NATs are symmetric.

Modern terminology (RFC 4787) separates the two axes — mapping behaviour (same external port for all destinations, or per destination) and filtering behaviour (who may send in) — because they are what NAT traversal actually depends on. The practical consequence: a home router is usually "endpoint-independent mapping, address-and-port-dependent filtering", which hole punching can defeat; a symmetric NAT is "endpoint-dependent mapping", which it usually cannot.

NAT behaviours
TypeExternal portAccepts inbound fromHole punching
Full conesame for all destinationsanyonetrivial
Address-restrictedsame for all destinationsany port of an IP you sent toworks
Port-restricted (home routers)same for all destinationsexactly the IP:port you sent toworks with STUN
Symmetric (CGNAT, enterprise)different per destinationexactly the IP:port you sent tousually fails → TURN relay

What NAT gives and what it breaks

The benefit is address sharing: one public address per household, office or VPC, which is what let IPv4 outlive its address space by twenty years. A secondary, incidental effect is that inside hosts are unreachable from outside unless a mapping exists — often described as security, but it is a side effect of a broken reachability model, and a stateful firewall provides the same protection with none of the following costs.

Inbound connections are the first casualty. Nothing can connect *to* 10.0.0.4 from outside, because there is no mapping until it sends something. Running a server behind NAT needs port forwarding (a static mapping: external 8080 → 10.0.0.4:80), UPnP (the host asks the router to create one), or a relay. Peer-to-peer — WebRTC calls, gaming, file sharing — needs both sides, each behind a NAT, to find a way through. STUN is a server on the public internet that tells a host "your packets arrive from 203.0.113.7:62014"; each peer learns its own external address that way, they exchange them through a signalling channel, and then both send to each other simultaneously so each NAT sees an outbound packet that opens the mapping before the inbound one arrives — hole punching. When one side is symmetric it fails, and the fallback is TURN: a public relay both sides connect *out* to, at the cost of an extra hop and a server bill. ICE is the procedure that tries all of these in order.

The subtler casualty is long-lived idle connections. Every mapping has a timeout — on Linux nf_conntrack the defaults are 5 days for established TCP, but home routers and CGNATs commonly use 5–30 minutes for TCP and 30–180 s for UDP, and some clouds drop idle TCP after 350 s. When the mapping expires, the next packet from either side is dropped or answered with a RST; the application sees a connection that was fine for an hour suddenly fail, or, worse, a write() that succeeds into a void until a much later timeout. This is why WebSockets ping, why database drivers and SSH have keepalive settings, why TCP has SO_KEEPALIVE (default interval 2 hours on Linux, far too long — set TCP_KEEPIDLE to under the NAT timeout), and why mobile push services carefully tune heartbeat intervals against carrier NATs. Finally, the translation table is finite: a burst of connections (or a scanner, or a worm) fills it and new mappings fail while old ones continue — the challenge Outbound calls fail from the whole cluster whenever traffic peaks walks through the diagnosis.

  • NAT also has to rewrite addresses that appear *inside* payloads — FTP, SIP — with application-layer gateways, which break as often as they help.
  • Traceroute and ICMP errors still work because the NAT translates the embedded header inside the ICMP payload.
  • Server logs see one IP for a whole office or a whole ISP region; per-IP rate limiting behind CGNAT punishes thousands of users for one.

CGNAT and the way out

Conceptual

When ISPs ran out of public addresses for customers, they put NAT in front of NAT: the home router masquerades to a 100.64.0.0/10 address, and a carrier-grade NAT masquerades that to a public one shared by hundreds or thousands of subscribers. Every cost above is doubled — two timeout budgets, two tables, symmetric behaviour, no port forwarding at all (you do not own the public address), and abuse reports that name a whole town. If your router’s WAN address starts with 100.x, you are behind CGNAT. Mobile networks are almost universally so.

IPv6 removes the *necessity*: with a public address per device, there is nothing to translate, inbound reachability is a firewall policy rather than an impossibility, peer-to-peer just works, and idle connections do not expire at a middlebox. The transition itself uses translation (NAT64 lets IPv6-only clients reach IPv4-only servers), and some operators run IPv6 NAT anyway out of habit, but in principle NAT is an IPv4 workaround with an expiry date. Until then, every system that opens outbound connections at scale, holds them idle, or needs to be reached from outside is designed around it.

Key points

  • NAT (strictly NAPT) rewrites the private source IP:port to the public IP and a chosen port, records the mapping, and reverses it on the reply. Neither end knows.
  • The port is the key that lets one public address front thousands of hosts; the translation table is a hash keyed by the 5-tuple.
  • Mapping and filtering behaviour define the type: home routers are port-restricted (hole punching works with STUN); CGNAT and enterprise NATs are often symmetric (TURN relay needed).
  • Inbound connections need port forwarding or a relay; peer-to-peer needs STUN/hole punching/TURN (ICE); idle connections need keepalives shorter than the NAT’s timeout.
  • The table is finite: a burst of connections fails new mappings while old ones continue. Timeouts: minutes for TCP on consumer gear, tens of seconds for UDP.
  • CGNAT stacks two NATs and doubles every cost; IPv6 removes the need for any of it in principle.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why did NAT win over deploying IPv6 in the 2000s?

It required changing one box per site instead of every host and every peer, and the client–server web needed only outbound connections. It was locally rational and globally expensive.

Why do mappings expire?

The NAT never sees a clean close for UDP and cannot trust it for TCP (the FIN may never come), so entries must age out or the table fills with the ghosts of every connection ever made.

Why is the NAT table a bottleneck when routers are not?

A router is stateless: per packet, a prefix lookup. A NAT keeps state per connection and must look it up and update it per packet, in memory that is finite and shared by every host behind it.

Why do keepalives fix "the connection died after an hour"?

Because the connection did not die at either end — the middlebox forgot it. A packet every few minutes refreshes the mapping’s idle timer so it is never forgotten.

NAT translation table

scenario
NAT translation table
Router: LAN 10.0.0.0/24 ↔ public 203.0.113.7. Every outbound flow writes a row; every inbound packet needs one.
IPv4
private (inside)     public (outside)      destination           state
(empty)
10.0.0.4 opens a connection: SYN from 10.0.0.4:51000 → 93.184.216.34:443 reaches the router. Private addresses are not routable on the Internet, so it must rewrite.
1/4 · 10.0.0.4 opens a connection

How it fails

What the failure looks like from inside real software.

  • Idle database or WebSocket connection fails after N minutes with a reset or a hang: the NAT mapping expired; set keepalives below the NAT timeout.
  • NAT table full: existing connections fine, new ones hang in SYN_SENT; the router or cloud NAT gateway reports allocation errors.
  • WebRTC call connects on the office network but not from a phone: symmetric CGNAT on mobile; no TURN server configured.
  • Self-hosted service unreachable from outside: no port forward, or the ISP uses CGNAT and forwarding is impossible; use IPv6 or a relay.
  • Per-IP rate limit blocks an entire university: every student shares one CGNAT address.
  • FTP or SIP works in one direction only: the application-layer gateway rewrote the wrong thing, or the payload was encrypted and it could not.