Build the mental model
An IPv4 address is 32 bits, so there are about four billion of them, and by the 1990s that was clearly not enough. Two answers emerged: IPv6 for the long term, NAT for right now. NAT's premise is simple. Most devices inside a network never need to be reachable from outside, so they need no globally unique address — unique within their own network is enough. RFC 1918 reserves three ranges for exactly this: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. Public routers never forward these, which is why millions of offices all use 192.168.1.10 without colliding.
What is deployed almost everywhere is not plain address translation but PAT (NAPT), which rewrites the port too. When an inside host sends a packet, the router replaces the source address with its own public one and the source port with a unique port it picks, recording in a translation table that this outside port belongs to that inside host and port. When the reply comes back, the router reads the destination port and knows where to send it. The port is not an optimisation, it is the whole mechanism: once every host shares one address, the port is the only thing left that tells them apart.
That is also where NAT's big limitation comes from: table entries exist only because an outbound packet created them. If someone outside opens a fresh connection to your public address, the router has no entry to consult, and drops it. This is why a home server is unreachable from the internet, and why port forwarding — a table entry added by hand ahead of time, 'anything on 8080 goes to 192.168.1.50:80' — must be configured rather than discovered.
NAT TRANSLATION: BEFORE, AFTER, AND THE TABLE
---------------------------------------------
BEFORE (inside the LAN) AFTER (on the Internet)
src 192.168.1.10:51230 --[NAT]--> src 198.51.100.7:50000
src 192.168.1.11:51230 --[NAT]--> src 198.51.100.7:50001
src 192.168.1.10:51231 --[NAT]--> src 198.51.100.7:50002
NAT TRANSLATION TABLE (state held only in the router's memory)
+--------------+-------------+--------------+----------+
| inside addr | inside port | outside port | protocol |
+--------------+-------------+--------------+----------+
| 192.168.1.10 | 51230 | 50000 | TCP |
| 192.168.1.11 | 51230 | 50001 | TCP |
| 192.168.1.10 | 51231 | 50002 | TCP |
+--------------+-------------+--------------+----------+
reply arriving for 198.51.100.7:50001
-> matches row 2 -> rewritten to 192.168.1.11:51230 DELIVERED
fresh inbound SYN to 198.51.100.7:8080
-> no row exists -> nothing to rewrite to DROPPED
-> port forwarding = a row you add by hand, ahead of timeConnect it to a real scenario
Say you want to reach a dashboard running on a Raspberry Pi in the office from home. The Pi serves on 192.168.1.50:80 and every laptop in the office can open it, but from home, hitting the office's public address 198.51.100.7 gets no response at all. The reason is not a firewall on the Pi; it is that the router's NAT table has no entry for this connection, because nothing inside ever sent a packet that would have created one.
There are three ways forward. The simplest is a port-forwarding rule on the router mapping outside port 8080 to 192.168.1.50:80, which is literally a permanent NAT entry installed by hand. The second is a reverse tunnel: the Pi opens an outbound connection to a server you control and traffic rides back over it. Because it is outbound, the entry is created automatically and the router needs no configuration at all. The third is dynamic DNS, but be clear what it does — it solves the separate problem of your ISP changing the public address, and does nothing about NAT itself. And if your ISP uses carrier-grade NAT, your router does not hold a public address in the first place, so port forwarding cannot work at any level and a reverse tunnel is the only option left.
Try the working example
import ipaddress
PUBLIC_IP = ipaddress.ip_address("198.51.100.7") # the router's WAN address
RFC1918 = [ipaddress.ip_network(n) for n in
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
def is_private(addr):
ip = ipaddress.ip_address(addr)
return any(ip in net for net in RFC1918)
class NAT:
"""Port Address Translation: many inside hosts, one outside address.
The key of the table is the OUTSIDE port, because that is the only
thing in the reply packet that can tell the hosts apart."""
def __init__(self, first_port=50000):
self.table = {} # outside port -> (inside ip, inside port)
self.next_port = first_port
def outbound(self, in_ip, in_port, dst_ip, dst_port):
for out_port, entry in self.table.items():
if entry == (in_ip, in_port):
break
else:
out_port = self.next_port
self.next_port += 1
self.table[out_port] = (in_ip, in_port)
return (str(PUBLIC_IP), out_port)
def inbound(self, out_port):
return self.table.get(out_port)
nat = NAT()
FLOWS = [
("192.168.1.10", 51230, "203.0.113.5", 443),
("192.168.1.11", 51230, "203.0.113.5", 443), # same inside port!
("192.168.1.10", 51231, "203.0.113.9", 80),
]
print("OUTBOUND: source address and port are rewritten")
print("inside socket becomes destination")
print("---------------------- ---------------------- ---------------")
for ip, port, dip, dport in FLOWS:
out_ip, out_port = nat.outbound(ip, port, dip, dport)
print("%-22s %-22s %s:%d"
% ("%s:%d" % (ip, port), "%s:%d" % (out_ip, out_port), dip, dport))
print()
print("NAT TRANSLATION TABLE")
print("outside port -> inside socket")
for out_port in sorted(nat.table):
ip, port = nat.table[out_port]
print("%12d -> %s:%d" % (out_port, ip, port))
print()
print("INBOUND: replies are matched by outside port")
for probe in (50001, 50099):
entry = nat.inbound(probe)
if entry:
print("reply to %s:%d -> forwarded to %s:%d"
% (PUBLIC_IP, probe, entry[0], entry[1]))
else:
print("reply to %s:%d -> NO TABLE ENTRY, dropped"
% (PUBLIC_IP, probe))
print()
for addr in ("192.168.1.10", "10.20.30.5", "172.20.0.1",
"172.32.0.1", "198.51.100.7"):
print("%-14s private(RFC1918)=%s" % (addr, is_private(addr)))OUTBOUND: source address and port are rewritten
inside socket becomes destination
---------------------- ---------------------- ---------------
192.168.1.10:51230 198.51.100.7:50000 203.0.113.5:443
192.168.1.11:51230 198.51.100.7:50001 203.0.113.5:443
192.168.1.10:51231 198.51.100.7:50002 203.0.113.9:80
NAT TRANSLATION TABLE
outside port -> inside socket
50000 -> 192.168.1.10:51230
50001 -> 192.168.1.11:51230
50002 -> 192.168.1.10:51231
INBOUND: replies are matched by outside port
reply to 198.51.100.7:50001 -> forwarded to 192.168.1.11:51230
reply to 198.51.100.7:50099 -> NO TABLE ENTRY, dropped
192.168.1.10 private(RFC1918)=True
10.20.30.5 private(RFC1918)=True
172.20.0.1 private(RFC1918)=True
172.32.0.1 private(RFC1918)=False
198.51.100.7 private(RFC1918)=False5-minute try-it
Add a duplicate flow from 192.168.1.10:51230 to FLOWS: does a new row appear, and why not? Then simulate port forwarding by pre-seeding the table with a fixed entry before any outbound traffic, and confirm that the inbound probe now succeeds — that single line is what a port-forwarding rule really is.
One important caution
Treating NAT as a security feature. Blocking inbound is a side effect, and UPnP or a single compromised inside host punches straight through it. You still need a real firewall.
Assuming 172.16.0.0/12 means only 172.16.x.x. It actually spans 172.16.0.0 to 172.31.255.255, while 172.32.0.0 is public address space — a classic subnet-design mistake.
RFC 1918 - Address Allocation for Private Internets — Computer Networking