Network Address Translation NAT

Network Address Translation (NAT) is the kernel facility that rewrites the IP addresses and transport-layer ports inside packets as they pass through a forwarding host, so that a flow appears to come from — or go to — a different address than it really does. In Linux this is not a separate subsystem but a consumer of two others: it is implemented as a set of netfilter hook functions that act on packets, and every rewrite is recorded on the flow’s connection-tracking entry so that all later packets of the same flow — including the replies coming back the other way — are translated consistently and automatically. The two canonical directions are Source NAT (SNAT), which rewrites the source address on the way out (a home or cloud gateway hiding many private hosts behind one public IP), and Destination NAT (DNAT), which rewrites the destination on the way in (port forwarding, load balancing). The whole edifice exists because the 32-bit IPv4 address space is too small to give every host a globally-unique address (per RFC 3022); NAT’s cost is the loss of end-to-end addressing. All facts here are pinned to the 6.12 LTS kernel (net/netfilter/nf_nat_core.c), spot-checked identical against 6.18 LTS, as of 2026-06-06.

NAT is one face of the netfilter family. The framework that provides the hook points is The Netfilter Framework and Hooks; the stateful flow table that NAT binds to is Connection Tracking conntrack; the two userspace rule languages that request NAT are iptables and x_tables (legacy) and nftables and the nf_tables Subsystem (modern). This note explains the translation mechanism itself — what gets rewritten, where, and how the binding is stored — and leans on those siblings for the machinery underneath rather than re-deriving it.


Mental Model: NAT Is a Mapping Bound to a Flow, Not a Rule Applied to a Packet

The single most important idea is that NAT is per-flow, not per-packet. A NAT rule does not fire on every packet. Instead, the first packet of a new connection is run past the NAT rules; whatever address/port rewrite they request is computed once, recorded on the connection’s conntrack entry, and from then on every packet of that flow is translated by replaying the stored mapping — without consulting the rules again. The netfilter NAT-HOWTO states this directly: “The first packet of a flow is used to look up for a matching rule which sets up the NAT binding for this flow”; subsequent packets use the established binding (nftables wiki).

This is why NAT needs conntrack. To translate the reply direction, the kernel must remember that “this incoming packet from R:80 to 1.2.3.4:34000 is actually the reply to the flow whose real source was 10.0.0.5:34000,” and rewrite the destination back to 10.0.0.5:34000. That memory is the conntrack entry. Without flow tracking there is no way to reverse a translation on the return path, and no way to keep two different inside hosts that happened to pick the same source port from colliding.

flowchart LR
  subgraph inside["Inside (private)"]
    H["Host 10.0.0.5:34000"]
  end
  subgraph GW["NAT gateway"]
    CT["conntrack entry<br/>ORIG: 10.0.0.5:34000 -> R:80<br/>REPLY: R:80 -> 1.2.3.4:34000<br/>status: IPS_SRC_NAT"]
  end
  subgraph outside["Outside (Internet)"]
    R["Server R:80"]
  end
  H -->|"1. out: src rewritten<br/>10.0.0.5 -> 1.2.3.4"| R
  R -->|"2. reply: dst rewritten back<br/>1.2.3.4 -> 10.0.0.5"| H
  CT -.->|"binds both directions"| GW

How a SNAT mapping lives on a conntrack entry. What it shows: the gateway rewrites the source on egress (arrow 1) and the same conntrack entry tells it to undo that on the matching reply (arrow 2) by rewriting the destination back to the real inside host. The insight: NAT stores the mapping on the flow, so the forward and reverse rewrites are two halves of one binding — translate the original direction’s chosen field, and invert it for the reply. The IPS_SRC_NAT status bit on the entry records that source-NAT is in effect.

Basic NAT vs NAPT (the address-only vs address+port distinction)

Two flavors exist. Basic NAT translates addresses only — a one-to-one map of inside address to outside address (RFC 2663: “translation in Basic NAT is limited to IP addresses alone”). NAPT (Network Address Port Translation) — what everyone actually means by “NAT” today, and what a home router does — also rewrites the transport identifier (the TCP/UDP port, or the ICMP query ID), so that many inside hosts can share one outside address by multiplexing them across the port space (RFC 3022: “translation in NAPT is extended to include IP address and Transport identifier such as TCP/UDP port or ICMP query ID”). Linux’s NAT engine does NAPT by default: if the source-port rewrite is needed to avoid a collision, it picks a free port for you. The port-rewriting machinery is detailed below.


Mechanical Walk-Through: How the NAT Engine Works

All of Linux NAT — whether requested by an iptables rule, an nftables snat/dnat/masquerade statement, or a conntrack-netlink call — funnels through one function in net/netfilter/nf_nat_core.c: nf_nat_setup_info() (6.12 LTS). Everything else is a frontend that builds an nf_nat_range2 (the min/max address and port to map into) and calls it. The proof that the two rule languages converge here is in the source: nft_nat_eval() ends with regs->verdict.code = nf_nat_setup_info(ct, &range, priv->type) (nft_nat.c), and the legacy iptables nat table registers ipt_do_table at the NAT hooks (iptable_nat.c), whose SNAT/DNAT targets call the same core. There is exactly one NAT engine; the frontends only differ in how the rule is expressed.

The hooks: where NAT runs in the packet pipeline

NAT is installed at four of netfilter’s five hook points, and which manipulation each hook performs is fixed by the hook’s position relative to the routing decision:

  • PRE_ROUTING (and LOCAL_OUT for locally-generated packets) → destination manipulation (DNAT). This must happen before routing, because rewriting the destination changes where the packet should go.
  • POST_ROUTING (and LOCAL_IN for packets delivered locally) → source manipulation (SNAT). This happens after routing, “just before it is finally sent out,” so that “anything else on the Linux box itself — routing, packet filtering — will see the packet unchanged” (netfilter NAT-HOWTO).

This mapping is hard-coded. In nf_nat_inet_fn() the kernel computes maniptype = HOOK2MANIP(state->hook) — PRE_ROUTING/LOCAL_OUT yield NF_NAT_MANIP_DST, POST_ROUTING/LOCAL_IN yield NF_NAT_MANIP_SRC. The legacy table confirms the priorities: iptable_nat.c registers PRE_ROUTING and LOCAL_OUT at NF_IP_PRI_NAT_DST and POST_ROUTING and LOCAL_IN at NF_IP_PRI_NAT_SRC. The nftables side enforces the same constraint at rule-load time: nft_nat_validate() rejects a dnat statement unless its chain hooks PRE_ROUTING or LOCAL_OUT, and a snat unless it hooks POST_ROUTING or LOCAL_IN. You cannot DNAT in postrouting; the kernel will not let you.

First packet: computing and storing the binding

When the first packet of a IP_CT_NEW flow reaches a NAT hook, nf_nat_inet_fn() checks nf_nat_initialized(ct, maniptype). If no binding exists yet, it runs the registered NAT rule entries; one of them calls nf_nat_setup_info() with the requested range. That function does the real work:

  1. It takes the conntrack entry’s reply tuple and inverts it to get the current original tuple (nf_ct_invert_tuple(&curr_tuple, &ct->tuplehash[IP_CT_DIR_REPLY].tuple)).
  2. It calls get_unique_tuple() to choose a new tuple that satisfies the requested range and does not collide with any existing flow (the port-allocation logic, below).
  3. If the new tuple differs from the current one, it rewrites the conntrack reply tuple so the entry will recognize replies: nf_ct_invert_tuple(&reply, &new_tuple); nf_conntrack_alter_reply(ct, &reply). This is the act that binds the NAT mapping to the flow — the conntrack entry’s reply side now describes the translated addresses, so returning packets will match.
  4. It sets the status bit on the entry: ct->status |= IPS_SRC_NAT (or IPS_DST_NAT), and later IPS_SRC_NAT_DONE / IPS_DST_NAT_DONE. These bits are how nf_nat_packet() later knows which direction to mangle.
  5. For source NAT it inserts the entry into a secondary hash, nf_nat_bysource, keyed on the original source tuple. This lets a new connection from the same inside source reuse the same outside source mapping (find_appropriate_src()), so a given inside host is seen by the server consistently — useful for “some Internet Banking sites” that pin clients to a source IP, per the in-code comment.

The translation request itself is carried in a struct nf_conn_nat extension attached to the conntrack entry (nf_ct_nat_ext_add()); NAT state is literally stored on the nf_conn, which is the concrete meaning of “the NAT mapping recorded in nf_conn.”

Every packet: replaying the mapping in both directions

Once the binding exists, actual byte-level rewriting happens in nf_nat_packet(). It reads the manip type for the hook and the flow’s direction:

if (mtype == NF_NAT_MANIP_SRC)  statusbit = IPS_SRC_NAT;
else                            statusbit = IPS_DST_NAT;
if (dir == IP_CT_DIR_REPLY)     statusbit ^= IPS_NAT_MASK;   // invert for replies
if (ct->status & statusbit)     verdict = nf_nat_manip_pkt(skb, ct, mtype, dir);

The XOR on the reply direction is the crux of two-way translation: a flow with IPS_SRC_NAT set will, on its original direction, have its source rewritten at POST_ROUTING; on its reply direction the bit flips so that the same POST_ROUTING/PRE_ROUTING traversal rewrites the destination back to the real inside host. One stored binding, applied symmetrically. nf_nat_manip_pkt() then edits the IP header (and recomputes the checksum) and the L4 header (port/ID and its checksum) in the [[struct sk_buff|skb]].

Port allocation and collision handling

The interesting part of get_unique_tuple() is choosing a free port when NAPT is in play. nf_nat_l4proto_unique_tuple() (6.12 LTS) shows the default source-port ranges when no explicit range is given: ports below 512 map into 1–511, ports 512–1023 map into 600–1023, and ports >= 1024 map into 1024–65535 — preserving the privileged/unprivileged split. It then searches for a free port, capped at NF_NAT_MAX_ATTEMPTS = 128 tries, because — as the in-code comment warns — “we are in softirq; doing a search of the entire range risks soft lockup when all tuples are already used.” If the first offset finds nothing, it picks a new random start and tries again with an ever-smaller window. “Free” is checked by nf_nat_used_tuple(), which inverts the proposed tuple and asks conntrack whether the reply already exists (nf_conntrack_tuple_taken()) — two flows must not produce an identical reply tuple, or returning packets could not be told apart. If, after all attempts, no unique tuple is found, the packet is allowed through and a final duplicate is caught at __nf_conntrack_confirm() time, where the packet is dropped — the worst case is a dropped packet, never a silent mis-translation.

Uncertain

Verify: the exact default source-port bucket boundaries (<512 → 1–511, 512–1023 → 600–1023, >=1024 → 1024–65535) and NF_NAT_MAX_ATTEMPTS = 128. Reason: read directly from nf_nat_core.c at the v6.12 tag and confirmed structurally identical at v6.18, but these are implementation constants that have shifted across kernel history and are easy to misquote. To resolve: re-read nf_nat_l4proto_unique_tuple() at the precise running kernel. uncertain


Configuration: Expressing NAT in iptables and nftables

The same engine, two languages. Both put NAT rules in a chain of type nat hooked at the right point.

iptables (legacy x_tables frontend)

All NAT lives in the nat table, selected with -t nat. The targets and their valid chains (from iptables-extensions(8)):

# SNAT — gateway with a static public IP 203.0.113.10, hide 10.0.0.0/24 behind it.
# SNAT is valid in POSTROUTING (and INPUT). -o names the egress interface.
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j SNAT --to-source 203.0.113.10
 
# MASQUERADE — same idea but the public address is dynamic (DHCP / PPPoE).
# Valid in POSTROUTING only; uses the egress interface's *current* address.
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
 
# DNAT — port forwarding: inbound :80 on the public side to an internal server.
# DNAT is valid in PREROUTING (and OUTPUT). -i names the ingress interface.
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.20:8080
 
# REDIRECT — DNAT to "this machine" on a different port (transparent proxy).
iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 3128

Line by line: --to-source ipaddr[-ipaddr][:port-port] and --to-destination specify the address (and optional port) range to map into. --random / --random-fully randomize the source port selection (the latter using a PRNG, kernel ≥ 3.14), defending against off-path port-prediction; --persistent “gives a client the same source-/destination-address for each connection.” Per the man page, MASQUERADE “should only be used with dynamically assigned IP (dialup) connections: if you have a static IP address, you should use the SNAT target,” because masquerade pays a per-packet cost to look up the interface address and, critically, “connections are forgotten when the interface goes down” — the right behavior when the next dialup may bring a different address (iptables-extensions).

nftables (modern nf_tables frontend)

In nftables you create the chain with an explicit hook and priority, then add snat/dnat/masquerade/redirect statements (nftables wiki):

nft add table ip nat
# Source NAT lives in a postrouting chain at priority 100 (srcnat).
nft 'add chain ip nat postrouting { type nat hook postrouting priority 100; }'
nft add rule ip nat postrouting ip saddr 10.0.0.0/24 oif "eth0" snat to 203.0.113.10
nft add rule ip nat postrouting oif "eth0" masquerade            # dynamic egress addr
 
# Destination NAT lives in a prerouting chain at priority -100 (dstnat).
nft 'add chain ip nat prerouting { type nat hook prerouting priority -100; }'
nft 'add rule ip nat prerouting iif "eth0" tcp dport { 80, 443 } dnat to 10.0.0.20'
nft add rule ip nat prerouting tcp dport 22 redirect to 2222

The priority numbers matter: -100 (dstnat) places DNAT before the routing decision in prerouting, 100 (srcnat) places SNAT late in postrouting, matching the iptables NF_IP_PRI_NAT_DST / NF_IP_PRI_NAT_SRC constants exactly. The random,persistent,fully-random flags can be appended (masquerade random,persistent). Unlike iptables, nftables requires no separate nat table to pre-exist — but the chain must be type nat, because that chain type is what registers the flow-binding NAT hook (nf_nat_inet_fn) rather than a plain packet filter.

Uncertain

Verify: that mainstream distributions in the 6.12/6.18 era ship iptables as a symlink to iptables-nft (the nftables-backed compatibility shim) by default, so the iptables commands above actually program nf_tables underneath. Reason: this is a distribution packaging decision, not a kernel fact, and varies (Debian/RHEL switched years ago; some embedded/container base images still carry iptables-legacy). To resolve: check iptables --version (it prints nf_tables or legacy) on the target system. uncertain


Failure Modes and Hard Cases

NAT needs conntrack — and inherits its limits

Because every binding lives on a conntrack entry, NAT inherits conntrack’s failure modes. If the conntrack table fills (nf_conntrack: table full, dropping packet), new NATed flows are dropped. If a UDP flow’s conntrack entry times out while still in use, the next packet creates a new entry and may be assigned a different source port, breaking the peer’s expectation. And any packet conntrack marks INVALID (out-of-window TCP, untrackable fragment) gets no NAT applied — nf_nat_inet_fn() returns NF_ACCEPT early if nf_ct_get() returns NULL, so such a packet leaves with its un-translated (often private) source address and is dropped upstream. Diagnose with conntrack -L and nstat//proc/net/stat/nf_conntrack. See Connection Tracking conntrack for the table-sizing and timeout details.

ALGs: NAT breaks protocols that carry addresses in their payload

NAT rewrites L3/L4 headers, but some application protocols embed IP addresses and ports inside the application payload. The textbook case is FTP in active mode: the client sends a PORT a,b,c,d,p1,p2 command containing its own IP and a port, telling the server where to open the data connection. After SNAT, that embedded private address is wrong, and the server tries to connect to an unroutable 10.x host. The fix is an Application-Level Gateway (ALG) — in Linux, a conntrack helper plus a NAT helper. RFC 2663 defines an ALG as an “application specific translation agent” that lets an application “connect to its counterpart running on a host in a different realm transparently.” The kernel’s nf_nat_ftp.c does exactly this: when conntrack’s FTP helper parses a PORT/PASV/EPRT/EPSV line, nf_nat_ftp() rewrites the embedded address/port in the payload, fixes up TCP sequence numbers for the length change (via the seqadj extension), and installs an expectation for the upcoming data connection with exp->expectfn = nf_nat_follow_master, so the data connection is NATed to match its control connection. SIP, PPTP (the GRE special-case in get_unique_tuple() keys off ct->master), and H.323 need similar helpers. ALGs are fragile, are a frequent source of CVEs, and many are disabled by default — which is exactly why modern peer-to-peer apps prefer ICE over relying on an in-network ALG.

Hairpin NAT (NAT loopback / NAT reflection)

A subtle case: an inside host tries to reach an inside server via the gateway’s public IP. The naive port-forward DNATs the destination to the internal server, but leaves the source as the inside client. The server then sees a packet from 10.0.0.5 to itself (10.0.0.6) — both on the same subnet — and replies directly to 10.0.0.5, bypassing the gateway. The client, which sent its packet to the public IP, receives a reply from 10.0.0.6 and rejects it as unsolicited; the connection hangs. The fix, hairpin NAT, is to also SNAT the hairpinned traffic to the gateway’s inside address, so the server’s reply is forced back through the gateway, which then un-translates both ends. It requires a DNAT for the forward path and a MASQUERADE/SNAT in POSTROUTING for traffic originating inside and destined to the public IP (OneUptime, NAT hairpinning). The general lesson: whenever a reply would not naturally return through the NAT device, you must SNAT the request so it does.

Loss of end-to-end addressing

NAPT’s structural cost is that “this solution has the disadvantage of taking away the end-to-end significance of an IP address” (RFC 3022). Inbound connections to an inside host are impossible without an explicit port-forward; protocols that assume a host knows its own externally-visible address break; and debugging is harder because the address a packet claims to be from is not where it originated. This is the architectural tax NAT pays for address conservation.


The Motivation: IPv4 Exhaustion and Private Addressing

NAT exists because IPv4 has only ~4.3 billion 32-bit addresses, far fewer than the number of hosts. RFC 1918 reserved three private address blocks — 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 — for hosts that “do not require access to hosts in other enterprises or the Internet at large,” with the explicit goal “to conserve the globally unique address space by not using it where global uniqueness is not required.” NAPT is the mechanism that lets an entire RFC 1918 network reach the Internet through one (or a few) public address(es): the classic SOHO scenario from RFC 2663, where users “have multiple Network nodes in their office… but have a single IP address assigned to their remote access router.” NAT bought the IPv4 Internet decades of extra life; IPv6, with a 128-bit address space, removes the need for NAT but has not removed its use (organizations still NAT IPv6 for policy reasons, though it is discouraged).


Container and Cloud NAT: The MASQUERADE That Powers Pod Egress

The most-deployed NAT rule on Earth today is almost certainly a single MASQUERADE for container egress. A container/pod sits in its own network namespace with a private address (e.g. 172.17.0.x for Docker’s default bridge, or a per-node pod CIDR for Kubernetes). When a pod talks to the Internet, its packets leave the node’s physical interface — but the cloud fabric “will reject” a packet whose source is the pod IP, because “traffic to external addresses must come from a known machine address” (Kubernetes ip-masq-agent). So the node SNATs/masquerades the pod’s source to the node’s own IP on the way out:

  • Docker installs, for each bridge network, a POSTROUTING MASQUERADE rule of the form -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE — traffic from the bridge subnet leaving via a different interface is masqueraded to that interface’s address. (The exact subnet/interface names are Docker-version- and config-dependent; inspect with iptables -t nat -L POSTROUTING on a Docker host to confirm.)
  • Kubernetes does the same via the ip-masq-agent (or kube-proxy / the CNI plugin). The agent “configures iptables rules to hide a pod’s IP address behind the cluster node’s IP address.” It is configured with nonMasqueradeCIDRs — the RFC 1918 ranges plus the cluster/pod CIDRs — so that intra-cluster traffic keeps its real pod source (NetworkPolicy and service routing need it) while only genuinely-external traffic is masqueraded. kube-proxy implements this with a packet mark (0x4000 by default): rules KUBE-MARK-MASQ set the mark, and a single KUBE-POSTROUTING rule masquerades all marked packets.

The mechanism is exactly nf_nat_masquerade_ipv4() from nf_nat_masquerade.c: it calls inet_select_addr(out, nh, RT_SCOPE_UNIVERSE) to read the egress interface’s current primary address, builds a single-address range, and hands it to nf_nat_setup_info() with NF_NAT_MANIP_SRC. It also records nat->masq_index = out->ifindex on the entry, so that when that interface goes down (NETDEV_DOWN), a background worker walks the conntrack table and tears down every masqueraded flow that used it (device_cmp() / nf_nat_masq_schedule()) — the kernel-level reason masquerade “forgets connections when the interface goes down.” Both the iptables MASQUERADE target (xt_MASQUERADE.c) and the nftables masquerade statement (nft_masq.c) call this same function, restricted to POST_ROUTING. The orchestration view — how a CNI plugin and kube-proxy program these rules — lives in Kubernetes MOC; the kernel mechanism is here.


Alternatives and When to Choose Them

  • SNAT vs MASQUERADE. Identical effect (rewrite source to an outside address). Use SNAT with --to-source when the egress address is static — it is cheaper, since the address is fixed at rule-load time. Use MASQUERADE only when the address is dynamic (DHCP, PPPoE, or a cloud interface whose address you do not want to hard-code), accepting the per-packet inet_select_addr lookup and the flush-on-link-down behavior.
  • DNAT vs REDIRECT. DNAT --to-destination sends traffic to an arbitrary host/port. REDIRECT is the special case “to this box” — it rewrites the destination to the incoming interface’s primary address (or 127.0.0.1 for locally-generated packets), the standard transparent-proxy pattern.
  • iptables NAT vs nftables NAT. Same kernel engine; prefer nftables for new work (one unified ruleset, atomic updates, maps/sets for efficient many-target DNAT). See nftables and the nf_tables Subsystem vs iptables and x_tables.
  • Netfilter NAT vs IPVS / XDP load balancing. For load-balancing DNAT at high scale, IPVS (a dedicated L4 balancer) or an XDP program (Katran/Cilium-style, acting before an skb exists) avoid per-connection conntrack cost. Netfilter NAT is the general-purpose, stateful choice; XDP is the line-rate, stateless-DNAT choice.
  • NAT vs end-to-end (IPv6 / NAT traversal). Where end-to-end reachability matters (peer-to-peer, WebRTC), the answer is not more NAT but to traverse it — ICE — or to deploy IPv6 and skip NAT entirely.

Production Notes

The two failure signatures worth memorizing: a dropped first packet under load that turns out to be nf_conntrack: table full (size net.netfilter.nf_conntrack_max, watch nf_conntrack_count), and a port-exhaustion symptom where a busy SNAT gateway with few public IPs runs out of the ~64k source ports per (public-IP, protocol, destination) tuple and new connections stall while get_unique_tuple() burns its 128 attempts. Mitigations: add more public addresses to the SNAT range (the engine spreads load across them via find_best_ips_proto()), or shorten conntrack timeouts so freed ports recycle faster.

The most famous NAT-related production incident is the Kubernetes ~5-second DNS timeout, traced by Weaveworks to a kernel SNAT-insertion race in conntrack (Weave issue #3287; “Racy conntrack and DNS lookup timeouts”, 2018). When a pod fires its parallel A and AAAA UDP DNS queries at nearly the same instant, both flows reach the masquerade rule, both run nf_nat_setup_info() on different CPUs, and they can pick the same translated source tuple — one then loses the __nf_conntrack_confirm() race and is silently dropped, so the resolver waits out its ~5s retry timer. The symptom is a rising insert_failed counter in conntrack -S. The fix is to set the NF_NAT_RANGE_PROTO_RANDOM_FULLY flag on the masquerade rule (--random-fully in iptables, fully-random in nftables), which spreads the chosen source ports with a PRNG and makes the collision astronomically unlikely; NodeLocal DNSCache (forcing TCP / a local cache) is the orthogonal mitigation. (The TCP-only nf_nat_used_tuple_harder() TIME_WAIT-eviction path in nf_nat_core.c is a separate port-exhaustion hardening — it does not address this UDP race.) When debugging NAT, conntrack -L -j shows the live bindings (original and reply tuples side by side), which is the single most useful diagnostic — it shows you precisely what mapping the engine chose.


See Also