NAT and Port Forwarding
On this page
Sharing One Address Among Many#
Network Address Translation (NAT) is the trick that bought IPv4 several extra decades. Instead of giving every device a globally routable address, a NAT router lets a whole private network share one public IP by rewriting addresses and ports as packets cross the boundary. It is in every home router, every cloud "NAT gateway", every Kubernetes node and most mobile carriers, and it is behind a whole family of "works outbound, fails inbound" and "connection dropped after idling" problems.
How NAT Works#
When a device at 192.168.1.20:41230 opens a connection to 198.51.100.14:443, the router rewrites the source to its own public address and a free port, say 203.0.113.7:40000, and records the mapping:
inside public (translated) remote
192.168.1.20:41230 <--> 203.0.113.7:40000 <--> 198.51.100.14:443
192.168.1.21:41230 <--> 203.0.113.7:40001 <--> 198.51.100.14:443
Two inside hosts may use the same source port; the router keeps them apart by giving each a different public port. Replies arriving at 203.0.113.7:40000 are looked up and rewritten back to 192.168.1.20:41230. Using ports to multiplex many hosts behind one address is technically PAT (Port Address Translation) or NAT overload, and it is what nearly every "NAT" actually does. Rewriting the source is SNAT; rewriting the destination, as port forwarding does, is DNAT.
A small simulation shows why unsolicited inbound traffic cannot get in:
import itertools, time
class PatRouter:
def __init__(self, public_ip, idle_timeout=30):
self.public_ip = public_ip
self.idle_timeout = idle_timeout
self.ports = itertools.count(40000) # real routers pick from a pool, often randomized
self.out = {} # (proto, inside_ip, inside_port, dst_ip, dst_port) -> public_port
self.back = {} # (proto, public_port) -> [inside flow, last_seen]
def outbound(self, proto, src_ip, src_port, dst_ip, dst_port):
key = (proto, src_ip, src_port, dst_ip, dst_port)
if key not in self.out:
port = next(self.ports)
self.out[key] = port
self.back[(proto, port)] = [key, time.monotonic()]
port = self.out[key]
self.back[(proto, port)][1] = time.monotonic()
return (self.public_ip, port, dst_ip, dst_port) # rewritten source
def inbound(self, proto, src_ip, src_port, public_port):
entry = self.back.get((proto, public_port))
if entry is None:
return "DROP: no mapping (unsolicited)"
(p, in_ip, in_port, dst_ip, dst_port), last_seen = entry
if time.monotonic() - last_seen > self.idle_timeout:
return "DROP: mapping expired"
if (src_ip, src_port) != (dst_ip, dst_port):
return "DROP: reply from an unexpected peer"
return f"deliver to {in_ip}:{in_port}"
r = PatRouter("203.0.113.7")
print(r.outbound("tcp", "192.168.1.20", 41230, "198.51.100.14", 443))
print(r.outbound("tcp", "192.168.1.21", 41230, "198.51.100.14", 443))
print(r.inbound("tcp", "198.51.100.14", 443, 40000))
print(r.inbound("tcp", "198.51.100.99", 443, 40000))
print(r.inbound("tcp", "198.51.100.14", 443, 45555))
('203.0.113.7', 40000, '198.51.100.14', 443)
('203.0.113.7', 40001, '198.51.100.14', 443)
deliver to 192.168.1.20:41230
DROP: reply from an unexpected peer
DROP: no mapping (unsolicited)
The three drop reasons are the three ways NAT breaks connections in real life: no mapping exists (inbound connections), the mapping timed out (idle connections), and the packet came from a different peer than the mapping expects (peer-to-peer traffic).
NAT Behaviour Types#
Real NATs differ in how they allocate and filter mappings, which decides whether peer-to-peer connections can work. RFC 4787 describes them in terms of mapping and filtering; older literature uses cone names:
| Behaviour | Mapping | Filtering | P2P friendliness |
|---|---|---|---|
| Full cone | same public port for all destinations | anyone may send to it | easy |
| (Address-)restricted cone | same public port for all destinations | only hosts you have contacted | good |
| Port-restricted cone | same public port for all destinations | only exact IP:port you contacted | workable with hole punching |
| Symmetric | new public port per destination | only that destination | needs a relay |
Video calls, games and VPNs such as WireGuard-based meshes use STUN to learn their public address and port, UDP hole punching (both peers send to each other at once, so each NAT sees outbound traffic first and creates a mapping), and fall back to a TURN relay when both sides are behind symmetric NAT. The ICE framework orchestrates this in WebRTC.
The NAT Table, Timeouts and Exhaustion#
Each active flow consumes one entry and, per public IP and protocol, one of roughly 64,000 usable ports. Entries expire after an idle timeout, typically minutes to hours for established TCP and seconds to a few minutes for UDP, depending on the device. Consequences you will meet:
- Silent drops of idle connections. A database connection or SSH session idle for longer than the NAT timeout is dead, but neither end knows until it sends and gets no reply or a reset. Fix with keepalives shorter than the timeout:
ServerAliveInterval 60in SSH config, TCP keepalive settings on connection pools, or application-level pings on WebSockets. - Port exhaustion. A cloud NAT gateway serving many workers that all call the same API endpoint can run out of source ports for that destination, because each flow to the same destination IP and port needs a unique public port. Symptoms are intermittent connection timeouts under load. Fixes: reuse connections (HTTP keep-alive, connection pooling), add public IPs to the gateway, or spread load across destinations.
- Conntrack table full on Linux. A Linux NAT box or Kubernetes node logs
nf_conntrack: table full, dropping packetwhen its tracking table fills. Check withsysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_maxand raise the limit or reduce short-lived connections.
Doing NAT on Linux#
A Linux box becomes a NAT router with forwarding plus two rules. With nftables:
sudo sysctl -w net.ipv4.ip_forward=1
sudo nft add table ip nat
sudo nft add chain ip nat postrouting '{ type nat hook postrouting priority 100 ; }'
sudo nft add chain ip nat prerouting '{ type nat hook prerouting priority -100 ; }'
# SNAT: everything leaving via the internet-facing interface takes its address
sudo nft add rule ip nat postrouting oifname "eth0" masquerade
# DNAT (port forward): public port 8080 -> internal web server port 80
sudo nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 dnat to 192.168.1.50:80
The older iptables equivalents are iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE and iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 192.168.1.50:80. Either way, the filter rules must also permit the forwarded traffic, or the DNAT happens and the packet is then dropped. MASQUERADE uses whatever address the interface currently has, which suits dynamic WAN addresses; SNAT --to-source is slightly cheaper for a fixed one.
To see live translations, install conntrack-tools and run sudo conntrack -L -p tcp --dport 443. Each entry shows the original tuple and the reply tuple, so you can read the rewrite directly.
Inbound Traffic: Port Forwarding#
NAT naturally drops unsolicited inbound packets because no mapping exists. To expose an internal service, you add a port forwarding (static DNAT) rule: "traffic arriving on public TCP port 8080 goes to 192.168.1.50:80."
- Static forward: a fixed public port to a fixed internal host. Give that host a DHCP reservation so its address never changes.
- UPnP / NAT-PMP / PCP: applications request mappings automatically. Convenient for consoles and games, but any compromised device on the LAN can open ports too. Disable it on networks you care about.
- DMZ host: forwards all unhandled inbound ports to one host. Only appropriate for a machine you intend to expose completely and have hardened accordingly.
Testing a forward from inside the same network is misleading (see hairpinning below). Test from outside, for example from a phone on mobile data or a cloud VM: nc -vz <public-ip> 8080. "Connection refused" means the forward works but nothing listens on the internal port; a timeout means the forward, the host firewall or the ISP is blocking.
CGNAT#
As IPv4 ran out, carriers deployed Carrier-Grade NAT (CGNAT): a second NAT layer inside the ISP, shared by many subscribers, typically using the 100.64.0.0/10 range between your router and the carrier. If your router's WAN address is in that range, or differs from the public address websites see, you are behind CGNAT, and port forwarding on your router cannot work because the carrier's NAT drops the traffic first.
CGNAT also means many unrelated users share one public IP, which is why IP-based blocking and rate limiting can punish innocent neighbours, and why reputation systems treat mobile and CGNAT addresses carefully (see IP Reputation).
Hairpinning#
A device inside the network tries to reach an internal service by its public address. The packet goes to the router, gets DNATed to the internal server, but the server sees the client's internal source address and replies directly, bypassing the router. The client receives a reply from an address it never contacted and drops it. Enable NAT loopback / hairpin NAT on the router (which also SNATs the hairpinned traffic), or use split-horizon DNS so internal clients resolve the name to the internal address.
Security Implications#
NAT gives an accidental default-deny for inbound traffic, which is why so many poorly hardened home devices survive. It is not a firewall: it does nothing against outbound malware, UPnP holes, or attacks from inside. With IPv6, every device can have a globally routable address and that accidental protection disappears, so a stateful firewall that allows established traffic and denies unsolicited inbound is mandatory.
NAT and Application Protocols#
Some protocols embed IP addresses inside their payload: active-mode FTP, SIP and older VoIP signalling. NAT rewrites headers but not those embedded addresses, so the far end tries to connect to a private address. Application Layer Gateways (ALGs) inspect and rewrite such payloads, but they are a frequent source of bugs themselves (SIP ALGs are notorious for breaking VoIP). Modern practice avoids protocols that embed addresses: passive FTP, ICE for media, or tunnelling over HTTPS.
Port Forwarding vs. Reverse Tunnels#
Port forwarding needs a reachable public IP and open inbound ports, increasingly unavailable under CGNAT and undesirable for security. The alternative is a reverse tunnel: the internal service dials out to a relay and keeps the connection open, so inbound requests travel back through it. Tools such as ngrok, Cloudflare Tunnel and Tailscale Funnel work this way, and SSH does it natively:
# On the internal machine: expose local port 80 as port 8080 on a server you control
ssh -N -R 8080:localhost:80 user@relay.example.net
No inbound port on your network is opened, and the relay becomes the single place to add authentication. See VPN & Tunneling.
Practice#
On a Linux VM with two interfaces (or a container network), configure masquerading and one DNAT rule as above, open a connection through it, and find the corresponding entry in conntrack -L. Identify the original and reply tuples, then wait past the UDP timeout with a UDP flow and watch the entry disappear.
Check your understanding
4 quick questions on this lesson. Get them all right and the lesson is marked complete.