# NAT and conntrack: The Stateful Lie Your Router Tells

> NAT rewrites addresses; connection tracking is the memory that makes the rewrite reversible. Together they're why the internet survived IPv4 exhaustion — and why your idle SSH session dies, your table overflows, and P2P needs hole-punching.

URL: https://harshith.in/blog/nat-and-conntrack/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: networking, linux, systems

NAT was supposed to be a stopgap — a 1994 hack to stretch IPv4 until the
real fix arrived. Thirty years on, it's load-bearing infrastructure on
every home router, every cloud VPC gateway, and every Kubernetes node,
and its side effects (dying idle connections, unreachable peers, a
kernel table that fills up during traffic spikes) are so pervasive that
whole protocol families exist just to work around it. Understanding NAT
means understanding the state table underneath — on Linux, **conntrack**.

## The Rewrite, and the Memory It Requires

Your laptop (10.0.0.5) talks to a server; the router masquerades:

```text
  out: 10.0.0.5:53012 → 93.184.216.34:443
       rewritten to  203.0.113.7:53012 → 93.184.216.34:443

  in:  93.184.216.34:443 → 203.0.113.7:53012
       ...which internal host? The packet alone cannot say.
```

The return packet is unanswerable without memory. So the router records
each flow in a state table keyed by 5-tuple; every reply is matched
against it and un-rewritten. (When two internal hosts pick the same
source port, the NAT rewrites the port too — the table, not the packet,
is the source of truth.) That's the fundamental deal: **NAT converts the
stateless IP internet into stateful infrastructure**, and every NAT
pathology is a property of that state.

On Linux the machinery is explicit: `conntrack -L` dumps the live table
(`sudo conntrack -E` streams events); entries carry protocol state and a
countdown. iptables/nftables NAT rules only see a flow's *first* packet
— everything after follows the conntrack entry, which is why editing NAT
rules doesn't affect established flows, a fact that has confused every
firewall admin at least once.

## The Pathologies, Enumerated

**State expires; connections don't know.** An idle TCP flow's entry
times out (established default is generous — days — but *middlebox* NATs
and stateful clouds are far stingier, minutes to hours); UDP gets 30–180
seconds. The next packet arrives at a NAT with no memory of the flow and
is dropped or RST'd — the classic "my SSH session dies overnight," and
why long-lived-connection software universally ships keepalives tuned
below common NAT timeouts (SSH `ServerAliveInterval`, TCP keepalive,
WireGuard `PersistentKeepalive = 25` — that specific number is UDP NAT
timeout folklore crystallized into a default).

**The table is finite.** `nf_conntrack_max` (scaled from RAM) caps
entries; at the cap, new flows are dropped with the infamous dmesg line
`nf_conntrack: table full, dropping packet` — a signature incident on
busy gateways, load balancers, and Kubernetes nodes (where kube-proxy
puts *every service connection* through conntrack). Short-lived flood
traffic (DNS, health checks, port scans) fills it fastest since even
closed flows linger in TIME_WAIT-ish states. Fixes in honest order:
raise the max and shrink timeouts, exempt traffic that doesn't need
tracking (`nftables notrack` for high-rate stateless DNS), or stop
NAT-ing that path entirely.

**Inbound is undefined.** Without an existing entry, an unsolicited
inbound packet has no answer — that accidental firewall is most of a
home network's security posture. Port-forwarding (DNAT) pre-seeds the
mapping; **hairpin NAT** covers the corner where an internal client
hits its own public IP (routers that don't support it create the
"works from LTE, fails from my LAN" mystery). And two NAT'd peers who
both lack inbound reachability meet via **hole punching**: both send
outward (creating mappings), a rendezvous server swaps the observed
public endpoints, and each peer's packets thread the other's freshly
punched hole. Whether that works depends on mapping behavior —
endpoint-independent NATs ("full cone") punch easily; symmetric NATs
(per-destination mappings) defeat it, forcing TURN-style relays. The
entire STUN/ICE/WebRTC stack, and tailscale-style meshes, are
engineering built on classifying and exploiting *router state-table
behavior* — protocol design downstream of a hack.

CGNAT stacks the hack twice (your "public" IP is itself private,
port-block-allocated across subscribers), breaking port forwarding
outright and making per-IP rate limiting punish whole neighborhoods.
IPv6 removes the *address-scarcity* reason for all of this; the
stateful-firewall behavior survives by policy, hole punching and
keepalives with it. NAT's stopgap became a permanent layer of the
stack, which is the most networking lesson a lesson has ever been.

## Takeaways

- NAT is a rewrite plus a state table; replies are only routable because
  conntrack remembers the flow — rules see first packets, state sees
  the rest.
- Idle flows outlive their NAT entries: keepalives under the timeout
  (WireGuard's 25 s is the canonical UDP number) are mandatory hygiene.
- nf_conntrack table-full drops are a classic gateway/K8s incident:
  raise limits, cut timeouts, notrack stateless floods.
- Unsolicited inbound needs pre-seeded state (DNAT) or hole punching;
  symmetric NATs defeat punching and force relays — hence STUN/ICE/TURN.
- conntrack -L/-E makes every claim here observable on your own router.
