skip to content

When a NAT hairpins an inside client's TCP connection to an inside server, which addresses must it rewrite, and why is destination-only rewriting not enough?

level: middleimportance: must knowfreq 32%

answer

  1. two rewrites, not one
  2. where does the reply go
  3. same subnet, direct delivery
  4. external source address and port

basics

~20 s

A hairpinning NAT rewrites both the destination, to the server's inside endpoint, and the source, to the client's external mapped endpoint. Rewriting only the destination lets the server reply directly, so the client gets an answer from an unexpected address.

solid answer

~50 s

The NAT must rewrite the **destination** from the public endpoint `203.0.113.10:443` to the server's inside endpoint, and the **source** from the client's inside endpoint to its **external mapped endpoint**, such as `203.0.113.10:61001`; RFC 4787 REQ-9a and RFC 5382 REQ-8a require that "External source IP address and port" behaviour. The source rewrite forces the server's reply back through the NAT, which reverses both rewrites so the client hears from exactly the endpoint it called. With destination-only rewriting, a server on the client's subnet sees an inside source and answers it directly. The client's TCP has no connection matching `10.20.0.10:443`, so it resets that segment and keeps retrying the public address until it times out. If client and server sit on different subnets routed by the NAT, the reply crosses the NAT anyway, which is why partial hairpinning sometimes works.

code

pseudocode · 16 lines
pseudocode
on packet p arriving on the INSIDE interface:
    target = mappings.find_by_external(p.proto, p.dst_ip, p.dst_port)
    if target is null:
        translate_outbound(p)          # ordinary traffic to the Internet
        send(p, OUTSIDE)
        return
    # destination is one of our own public endpoints: hairpin
    sender = mappings.find_or_create(p.proto, p.src_ip, p.src_port)
    p.dst_ip, p.dst_port = target.inside_ip, target.inside_port
    p.src_ip, p.src_port = sender.external_ip, sender.external_port
    send(p, INSIDE)

# request: 10.20.0.55:50123 -> 203.0.113.10:443
#   becomes 203.0.113.10:61001 -> 10.20.0.10:443
# reply:   10.20.0.10:443 -> 203.0.113.10:61001
#   becomes 203.0.113.10:443 -> 10.20.0.55:50123

go deeper

for a junior

Remember that a hairpinned packet has two addresses changed, the destination and the source, and that the source change is what brings the reply back through the NAT.

for a middle

Walk through the four-step trace with addresses, and show where destination-only rewriting breaks: the server's direct reply and the client's reset.

for a senior

Explain why partial hairpinning works across subnets but not within one, and what the source rewrite costs in client visibility and translator load.

for a principal

Judge when to rely on hairpinning's source rewrite, with its loss of client identity, versus routing inside clients to inside addresses so servers keep seeing who connected.

## The setup Use documentation addresses throughout. The NAT's public address is `203.0.113.10`; a port forward maps `203.0.113.10:443` to the inside server S at `10.20.0.10:443`. Inside client C is `10.20.0.55` and opens a TCP connection from port `50123`. C and S sit on the same inside subnet, `10.20.0.0/24`, and the NAT's inside address is `10.20.0.1`. C resolves the service name to `203.0.113.10` and sends a SYN. Because `203.0.113.10` is not on C's subnet, the SYN goes to the default gateway, the NAT. ## The full hairpin: both addresses rewritten RFC 4787 REQ-9a (UDP) and RFC 5382 REQ-8a (TCP) require the hairpinned packet to carry the **"External source IP address and port"**: the mapped public endpoint of the originating inside host. Here the NAT allocates `203.0.113.10:61001` for C. | Step | Where | Source | Destination | |---|---|---|---| | 1 | C to NAT | `10.20.0.55:50123` | `203.0.113.10:443` | | 2 | NAT to S | `203.0.113.10:61001` | `10.20.0.10:443` | | 3 | S to NAT | `10.20.0.10:443` | `203.0.113.10:61001` | | 4 | NAT to C | `203.0.113.10:443` | `10.20.0.55:50123` | 1. In step 2 the NAT applies the port forward to the **destination** and C's outbound mapping to the **source**. 2. In step 3 S sees a peer at `203.0.113.10`, which is not on its subnet, so its reply goes to its default gateway: the NAT. 3. In step 4 the NAT reverses both rewrites. C receives a SYN-ACK from exactly the endpoint it called, and the handshake completes. ## Destination-only rewriting: why it fails Suppose the NAT applies the forward but leaves the source alone. | Step | Where | Source | Destination | |---|---|---|---| | 1 | C to NAT | `10.20.0.55:50123` | `203.0.113.10:443` | | 2 | NAT to S | `10.20.0.55:50123` | `10.20.0.10:443` | | 3 | S to C, directly | `10.20.0.10:443` | `10.20.0.55:50123` | S sees a SYN from `10.20.0.55`, a host on its own subnet, so it sends the SYN-ACK **straight to C** over the local link. The NAT never sees the reply, so it cannot restore the source to `203.0.113.10:443`. C's TCP is waiting for an answer from `203.0.113.10:443`. A segment from `10.20.0.10:443` matches no connection it holds. The TCP specification (RFC 9293) says a segment that arrives for a connection that does not exist is answered with a reset, so C sends an RST to S, which drops its half-open connection. Meanwhile C keeps retransmitting the SYN to the public address until it gives up. The user sees a hang, not an error. The source rewrite is what makes the reply **come back through the NAT**. A connection is a pair of endpoints, and both ends must keep seeing the same pair; only the device that changed the addresses can change them back. ## When destination-only rewriting still works If C were on another inside subnet, say `10.30.0.0/24`, routed by the same NAT device, S's reply to `10.30.0.55` would have to cross that device anyway. The NAT would see it and reverse the destination rewrite, so C would get the expected source. That is why hairpinning sometimes "works for some offices and not others": the difference is whether the reply path crosses the translator. The RFCs still require the external source even then. Their justification is application behaviour: some applications accept packets only from the address and port they expect, and peers that know each other only by their external endpoints need to see those endpoints. ## Variants and costs - **Internal source behaviour.** RFC 4787 names a second behaviour, "Internal source IP address and port", in which the packet keeps C's inside endpoint, and warns that it confuses implementations expecting the external one; REQ-9a rules it out. - **The inside-address shortcut.** Some implementations rewrite the source to the NAT's own inside address, `10.20.0.1`. That also forces replies back through the NAT, but it is an implementation choice, not the RFC behaviour. - **Lost client identity.** With either source rewrite, S logs every hairpinned client as the same address, so per-client logging, rate limits and address-based allow lists stop telling inside users apart. - **Load on the NAT.** Every inside session to the public address now takes a translation entry, and both its requests and its replies cross the NAT. ## The same logic as one lookup In mapping-table terms, the NAT runs one check on every packet arriving on its inside interface: if the destination is one of its own public endpoints, it rewrites the destination to that mapping's inside endpoint and the source to the sender's own external mapping, then sends the packet back inside. The request matches the port forward and the reply matches C's mapping, so each packet gets both rewrites and the second pair undoes the first.

  • Why do RFC 4787 and RFC 5382 require the external source address and port instead of leaving the client's inside endpoint on the hairpinned packet?
    Their justification is application behaviour: some applications accept packets only from the address and port they expect, and two endpoints that know each other only by their external endpoints must see those endpoints. RFC 4787 names the alternative, "Internal source IP address and port", and warns it confuses implementations expecting the external endpoint. On a shared subnet it would also let the server's reply bypass the NAT.
  • What does the inside server lose when the NAT hairpins with a source rewrite?
    It no longer sees which inside client connected. Every hairpinned session arrives from the client's external mapped endpoint, or from the NAT's inside address in implementations that use that shortcut, so logs, per-client rate limits and address-based allow lists treat all inside users as one address. Every such session also takes a translation entry and crosses the NAT in both directions.

saying these in an interview costs you the question

  • The NAT only needs to rewrite the destination, as with any port forward.
  • The server's reply always goes back through the NAT whatever the source address says.
  • A switch drops the reply because its IP source is not the public address.
  • TCP accepts a SYN-ACK from any address once it has sent a SYN.
  • The RFCs let a NAT choose either source behaviour for hairpinned packets.