OPNsenseXXXVII · Packet Flow MethodologyNAT in the packet flow
NAT in the packet flow — where translation happens and why it surprises operators
What you'll learn
- Describe where outbound NAT fits in the packet flow
- Describe where inbound NAT (port-forwarding) fits in the packet flow
- Read NAT translations in packet captures
- Diagnose NAT-related failures — missed translations, asymmetric translations, hairpin NAT
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
NAT (Network Address Translation) rewrites packet addresses as they flow through the firewall. Outbound NAT rewrites the source so internal hosts can share a public IP. Inbound NAT (port-forwarding) rewrites the destination so external traffic can reach an internal host. The order in which NAT applies surprises many operators, and the surprise usually comes from thinking of NAT as a stage that sits between PF and the wire. It is not: each translation is bound to an interface, and it runs on that interface’s pfil hook, immediately before the filter rules on the same hook. The operator who knows the order can predict what addresses a captured packet will have.
This lesson covers where NAT fits in the packet flow, how to read NAT translations in captures, the rules that drive NAT, and the NAT-related failure modes.
Where NAT fits
Outbound NAT and inbound NAT happen at different points in the packet flow:
- Outbound NAT (source translation). The generated rule is
nat on <egress> ..., so it fires on the outbound hook of the egress interface — which means it cannot run until routing has chosen that interface. It rewrites the source IP (and usually the source port) so the packet appears to come from the firewall’s WAN address. - Inbound NAT (port-forwarding, destination translation). The generated rule is
rdr on <ingress> ..., so it fires on the inbound hook of the interface the packet arrived on. It rewrites the destination IP and port to the redirect target.
Within a hook the order is fixed, and it is the same order on both hooks: translate, then filter. pf.conf(5) states it without qualification — “Since translation occurs before filtering the filter engine will see packets as they look after any addresses and ports have been translated. Filter rules will therefore have to filter based on the translated address and port number.”
Combine that with the two hooks a forwarded packet crosses and the whole picture falls out:
- Outbound traffic (LAN → WAN). Inbound hook on LAN — no translation is bound here, so the LAN rules see the host’s real source address. Routing picks WAN. Outbound hook on WAN — the
natrule rewrites the source, and only then are the WANoutrules evaluated, against the translated source. - Inbound traffic (WAN → LAN, port-forward). Inbound hook on WAN — the
rdrrule rewrites the destination, and the WAN rules are then evaluated against the internal address. Routing picks LAN. Outbound hook on LAN — the packet is already translated.
Outbound NAT in the flow
An outbound packet flow with NAT:
[Host A on LAN] → [LAN NIC] → [PF in on LAN: sees 192.0.2.50] → [Routing: egress is WAN] → [NAT on WAN: source rewritten to 203.0.113.1] → [PF out on WAN: sees 203.0.113.1] → [WAN NIC] → [Internet]
The capture on the WAN interface shows the rewritten source (the firewall’s WAN IP). The capture on the LAN interface shows the original source (the host’s LAN IP). Which of the two the filter log shows depends on the hook that logged the packet: an entry from a LAN in rule carries the original source, because no translation is bound to that hook; an entry from a WAN out rule carries the translated one, because the nat rule ran first. On a default OPNsense ruleset nearly every log entry comes from an inbound rule, which is why operators come to expect the original source — that is a fact about where the rules are, not about when NAT runs.
$ tcpdump -ni igb0 -c 1 'src host 192.0.2.50 and dst host 203.0.113.5'
echo '---'
tcpdump -ni igb1 -c 1 'dst host 203.0.113.5'12:34:56.789012 ... 192.0.2.50.51820 > 203.0.113.5.443: Flags [S], seq ..., length 0
---
12:34:56.789045 ... 203.0.113.1.51820 > 203.0.113.5.443: Flags [S], seq ..., length 0Illustrative output
Inbound NAT in the flow
An inbound packet flow with port-forwarding:
[Internet host] → [WAN NIC] → [rdr on WAN: destination rewritten to 192.0.2.50] → [PF in on WAN: sees 192.0.2.50] → [Routing: egress is LAN] → [LAN NIC] → [Internal host]
The capture on the WAN interface shows the original destination (the firewall’s WAN IP), because the capture tap sits ahead of the translation. The capture on the LAN interface shows the translated destination (the internal host’s IP). The WAN rules are evaluated after the rdr rule has run, so the rule that permits this flow names the internal IP.
$ tcpdump -ni igb1 -c 1 'dst host 203.0.113.1 and dst port 80'
echo '---'
tcpdump -ni igb0 -c 1 'dst host 192.0.2.50 and dst port 80'12:34:56.789012 ... 198.51.100.10.51820 > 203.0.113.1.80: Flags [S], seq ..., length 0
---
12:34:56.789045 ... 198.51.100.10.51820 > 192.0.2.50.80: Flags [S], seq ..., length 0Illustrative output
Reading NAT rules
OPNsense’s outbound NAT is in the GUI under Firewall → NAT → Outbound. Each rule has:
- Interface. The egress interface for the translation.
- Source. The original source address (host or network).
- Destination. The destination (often “any” for outbound).
- Translation. The translated source — typically the WAN interface address (the “WAN address” alias).
- Port preservation. Whether to preserve the original source port (for protocols that require it) or use a different port.
The active outbound NAT rules are visible with:
pfctl -s nat
$ pfctl -s natnat on igb1 inet from 192.0.2.0/24 to any -> (igb1:0) round-robin
nat on igb1 inet from 127.0.0.0/8 to any -> (igb1:0) round-robinIllustrative output
Inbound NAT (port-forwarding)
Inbound NAT is in the GUI under Firewall → NAT → Port Forward or Firewall → Rules → NAT. Each rule has:
- Interface. The ingress interface (typically WAN).
- Protocol. TCP or UDP.
- Destination port. The external port (e.g. 80 for HTTP).
- Redirect target IP. The internal IP.
- Redirect target port. The internal port (typically the same as the external).
When traffic arrives, the kernel rewrites the destination IP and port before PF evaluates. PF then matches a pass rule (typically the automatically-generated rule associated with the port-forward) and the packet is forwarded to the internal host.
Hairpin NAT (NAT reflection)
A common NAT surprise: a host on the LAN tries to reach the internal server by its public IP and port. The packet goes:
[Host A] → [LAN NIC] → [Routing: egress WAN?] → [Outbound NAT?] → ...
The host’s packet has the public IP as the destination. The routing table says the public IP is reachable via the WAN gateway. The packet goes out the WAN, gets a translation, comes back in… or the firewall recognises the loopback and applies inbound NAT, sending the packet back to the LAN.
This is hairpin NAT (or NAT reflection). It requires:
- The firewall to recognise that the destination is its own public IP.
- Apply inbound NAT to redirect to the internal host.
- Apply outbound NAT to rewrite the source so the host sees a response from the public IP.
OPNsense supports hairpin NAT but it requires specific configuration. Without it, the LAN host cannot reach the internal server by its public IP.
NAT-related failures
The signatures of NAT failures:
- Missed translation (outbound). Capture on LAN shows the host source; capture on WAN also shows the host source. The translation did not apply. Cause: no matching outbound NAT rule.
- Wrong translation (outbound). Capture on WAN shows a different public IP than expected. Cause: outbound NAT rule translated to a different address than intended.
- Missed translation (inbound). Capture on WAN shows the public IP as destination; capture on LAN shows the same public IP as destination. The translation did not apply. Cause: no port-forward rule.
- Wrong translation (inbound). Capture on LAN shows a different internal IP than expected. Cause: port-forward rule points to a different host.
- No associated pass rule. Capture on LAN shows the internal IP, but PF log shows the packet was blocked. Cause: port-forward exists but no pass rule was generated or the pass rule was removed.
Diagnosing NAT failures
The discipline for “the packet is at PF, PF passed it (or blocked it), but the translation is wrong”:
- Capture on both sides. Capture on ingress and egress (or LAN and WAN for outbound). Compare the addresses.
- Read the active NAT rules.
pfctl -s natshows what NAT applies. - Read the filter log — direction and interface first. The logged addresses are post-translation for any translation bound to the same hook. A block on the WAN inbound hook shows the port-forward’s internal destination; a block on the WAN outbound hook shows the NATted source; a block on the LAN inbound hook shows the host’s own addresses, because neither translation is bound there. Reading the addresses without reading the direction is how operators conclude that NAT “did not apply” when it did.
- Check for hairpin. If the source and destination are both on the LAN, hairpin NAT may be required.
- Check for related state. A NAT translation creates state; the operator verifies the state with
pfctl -s state.
Summary
- Translation always runs before filtering on the hook it is bound to, so filter rules are written against post-translation addresses.
- Outbound NAT is bound to the egress interface, so it runs on the outbound hook — after routing has chosen that interface, and before any
outrules on it. - Inbound NAT (port-forwarding) is bound to the ingress interface, so it runs on the inbound hook before the rules there. A port-forward’s filter rule must name the internal IP, not the public one — which is what the auto-generated associated rule does.
- Hairpin NAT is required for LAN hosts to reach internal servers by their public IP.
- NAT failures: missed translation, wrong translation, missing pass rule.
- The discipline: capture on both sides, read the NAT rules, read the PF log, check for hairpin and related state.
Knowledge check · 4 questions
Q1. A host on the LAN sends a packet to an Internet host. The capture on LAN shows source 192.0.2.50. The capture on WAN shows source 203.0.113.1 (the firewall WAN IP). What does this tell you?
Q2. PF evaluates the original (pre-NAT) destination for an inbound port-forwarded packet.
Q3. A LAN host tries to reach an internal server using its public IP and port. The connection fails. Which of the following could fix the issue? Select all that apply.
Q4. You added a port-forward rule but traffic to the external IP does not reach the internal host. The capture on WAN shows the public IP as destination. The PF log shows the packet was blocked by the default deny rule. What is the most likely cause?
Passing score: 75%. Answers are checked in this browser.