Skip to main content
RunBook Academy

OPNsenseXXXVII · Packet Flow MethodologyNAT in the packet flow

NAT in the packet flow — where translation happens and why it surprises operators

Intermediate⏱ ~14 minpfctltcpdumpWireshark

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

Not yet marked complete on this device.

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 nat rule rewrites the source, and only then are the WAN out rules evaluated, against the translated source.
  • Inbound traffic (WAN → LAN, port-forward). Inbound hook on WAN — the rdr rule 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.

Read-only / Safeoutbound NAT comparison
$ 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 0

Illustrative 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.

Read-only / Safeinbound NAT comparison
$ 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 0

Illustrative 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
Read-only / Safepfctl -s nat (NAT rules)
$ pfctl -s nat
nat 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-robin

Illustrative 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:

  1. The firewall to recognise that the destination is its own public IP.
  2. Apply inbound NAT to redirect to the internal host.
  3. 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.

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”:

  1. Capture on both sides. Capture on ingress and egress (or LAN and WAN for outbound). Compare the addresses.
  2. Read the active NAT rules. pfctl -s nat shows what NAT applies.
  3. 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.
  4. Check for hairpin. If the source and destination are both on the LAN, hairpin NAT may be required.
  5. 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 out rules 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

  1. 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?

  2. Q2. PF evaluates the original (pre-NAT) destination for an inbound port-forwarded packet.

  3. 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.

  4. 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.