TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Hairpin NAT Measurement 1: Our Domain Only Fails to Open Inside the Office

Status: Measured (2026-11-01). Values measured from the TT Lab cluster behind a home router. No router, cluster, or network settings were changed; only reads and temporary pods were used. Addresses and domains were rewritten to documentation examples (192.0.2.0/24, 203.0.113.0/24, app.example.com), while times and counts are actual values. Writing Rules: Measured data is the default; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Starting with the Conclusion

We opened a web service behind a router and attached a domain. When accessed via mobile data, it opens fine, but it does not open on a laptop connected to the same Wi-Fi. The server is fine and the DNS is also correct. This is the hairpin NAT problem.

A similar problem inside Kubernetes (when a pod requests its own Service) is covered in Part 2.

2. What is the Problem?

Routers in homes or small offices typically have a single public address and assign private addresses to internal devices. To send incoming requests from the outside to an internal server, you set up port forwarding. This is a rule like "Send traffic arriving at public address port 443 to server 192.0.2.10 port 443". The router changes the destination addressof incoming packets to the server address. This is called DNAT (Destination NAT).

This is sufficient for outside visitors. The server's reply follows the default route to the router, and the router uses the recorded connection information to revert the source to the public address and send it to the visitor. Since the visitor sent it to the public address and received a reply from the public address, there is no problem.

The problem is when the visitor is inside the same private network. When a laptop (192.0.2.20) looks up app.example.com , the public DNS returns the public address (203.0.113.7). The laptop sends a request to that address, and the request goes to the router, which is the default gateway. Here, there are three things the router can do:

  1. Do nothing. Port forwarding is not applied to packets coming from the private network side. The router receives the address thinking it is its own, but since nothing is listening on port 443, it discards or rejects it.
  2. Change only the destination (DNAT). This is the case in the diagram below.
  3. Change both destination and source (DNAT + SNAT). This is hairpin NAT.

When Only Changing the Destination

헤어핀 NAT 가 없을 때: 답이 다른 주소에서 온다 같은 사설망 안에서 서비스의 공인 주소로 접속하는 경우 클라이언트 192.0.2.20 공유기 안 192.0.2.1 밖 203.0.113.7 서버 192.0.2.10 192.0.2.20 → 203.0.113.7 ① 공인 주소로 보낸다 192.0.2.20 → 192.0.2.10 ② 목적지만 바꾼다(DNAT) 192.0.2.10 → 192.0.2.20 ③ 같은 망이라 공유기 없이 바로 답한다 클라이언트는 203.0.113.7 의 답을 기다린다 192.0.2.10 에서 온 답은 모르는 연결이라 버린다 연결이 맺어지지 않고 시간 초과로 끝난다
Diagram: When hairpin NAT is absent: If the router only changes the destination, the server replies directly to a client on the same network, and the client discards that reply.

When the router changes only the destination to the server address and forwards it, the source of the packet received by the server is the laptop's private address (192.0.2.20). From the server's perspective, the laptop is a neighbor on the same network, so it sends the reply directly to the laptop instead of routing it through the router. The source of the reply packet is the server address (192.0.2.10).

The laptop sent a SYN to establish a connection with port 443 of 203.0.113.7. However, the source of the returned SYN-ACK is 192.0.2.10. The laptop's TCP stack cannot match this as a reply to the connection it opened, so it treats it as an unknown packet (usually rejecting it with an RST). The laptop never receives the expected reply, sends a few more SYN packets, and eventually times out. Because the reply did not pass through the router, the router also has no opportunity to revert the address.

The key point is that requests and replies travel different paths. NAT requires both directions of a connection to pass through it in order to translate and revert addresses.

When Also Changing the Source: Hairpin NAT

헤어핀 NAT: 출발지도 바꿔 같은 길로 돌아오게 공유기가 목적지(DNAT)와 출발지(SNAT)를 함께 바꾼다 클라이언트 192.0.2.20 공유기 안 192.0.2.1 밖 203.0.113.7 서버 192.0.2.10 192.0.2.20 → 203.0.113.7 ① 공인 주소로 보낸다 192.0.2.1 → 192.0.2.10 ② 도착과 출발을 모두 바꾼다 192.0.2.10 → 192.0.2.1 ③ 답이 공유기로 돌아온다 203.0.113.7 → 192.0.2.20 ④ 되돌려 바꿔 전달한다 클라이언트는 기다리던 주소에서 답을 받는다 대가: 서버에는 사내 접속이 모두 공유기 주소로 보인다 규격(RFC 4787·5382)은 출발지를 공인 주소로 보이게 하라고 한다
Diagram: Hairpin NAT: The router changes both destination and source so that the server's reply returns to the router, and the router translates it back and delivers it to the client.

When the router changes the source to its own address along with the destination (②), the server assumes the request came from the router and sends the reply back to the router (③). The router reverts both addresses according to the connection log and passes it to the laptop with the source address 203.0.113.7 (④). Since the laptop received a reply from the address it was waiting for, the connection is established. Because the packet enters the router and bends back like a hairpin to exit toward the same side, it is called hairpin NAT. Loopback NAT and NAT reflection are other names referring to the same feature.

There is also a cost. From the server's perspective, all connections from within the private network appear to come from a single router . Internal users cannot be distinguished using access logs, and access limits or rate limits based on source addresses count all internal users as a single person. Since internal traffic passes through the router twice, it also consumes router throughput.

What the Specification Regards

IETF documents defining NAT behavior do not leave hairpinning as optional.

In the diagram, the source is depicted as the router's private-side address(192.0.2.1) for clarity, but what the specification requires is for the source to appear as a public address and externally used port. Either way, the core principle that the reply returns to the router remains the same. The "MASQUERADE" rule commonly used when building a software router with Linux—which triggers when the source is the private network and the destination is the server—translates to the private-side address approach. Among actual home routers, some products do not support hairpinning contrary to the specification or allow it to be disabled (the router measured this time also fell under 1 or 2 in Section 2; we will see which one in Section 5).

3. Four Solutions

If you can fix the router, that is the cleanest approach, but that is not always possible. There are broadly four methods, and in practice, two or more are often mixed.

Method Action When to Use Drawbacks
Split DNS DNS inside the private network returns the internal addressfor the same name When there are many internal devices/clusters and you operate your own DNS server Internal and external answers differ, which causes confusion when checking if "DNS is correct". If the internal address changes, two places must be updated. Does not work on devices like DoH that bypass internal DNS.
Hosts file Pins the name to the internal address on a single device For a few devices, in emergencies Must be manually updated on every device and gets forgotten. If you take your laptop outside, that name won't open externally.
Router settings Enables the router's hairpin (NAT loopback) feature When the router supports it and settings can be changed Names and locations vary by product, or the feature may be entirely absent. Internal connections appear to the server as coming from the router's address.
Routing through outside Internal devices access the public address via an external proxy, tunnel, or VPN When you need to test the exact path seen by external visitors Slow, costly, and introduces an additional external hardware failure point.

A few additional notes:

4. Measurement Method

We compared "accessing both the public address and the internal address using the same name" without changing settings across three locations.

Location How Name Resolution Works
Laptop (macOS, private network) Hosts file pins the name to the internal address. The public address side is forced with --resolve .
Cluster node (Ubuntu, private network) Hosts file actually contains the public address. The internal address side is forced with --resolve .
Temporary pod inside the cluster Cluster DNS (CoreDNS) hosts plugin returns the internal address for the name (Split DNS). The public address side only attempts a TCP connection.

The command structure looks like this. A 5-second limit was placed on each attempt.

# 공인 주소로 (DNS 를 건너뛰고 공인 주소를 강제, 이름은 그대로)
curl -sv --max-time 5 --resolve app.example.com:443:203.0.113.7 https://app.example.com/
# 내부 주소(게이트웨이)로
curl -sv --max-time 5 --resolve app.example.com:443:192.0.2.10 https://app.example.com/

External comparison was performed at four locations using a public checking service that sends HTTP requests from multiple countries (check-host.net HTTP check).

5. Measurement Results

Location To Public Address To Internal Address
Laptop 3/3 timeout (5.00~5.01s, stalls at connection phase). Port 80 also timed out. 3/3 HTTP 200, connection 2.8~4.0ms, total 23~28ms
Cluster Node 3/3 timed out (5.00s). Default connection following public addresses of hosts also timed out 3/3 HTTP 200, total 24~31ms
Temporary Pod TCP connection timed out (5.0s) Name resolved to internal address /healthz 200 (136ms, includes TLS connection as it was the first request)
Four external locations Three locations HTTP 200 (0.78~1.05s), one location "Connection reset by peer" N/A

It showed the exact pattern of the hairpin problem where it opens from the outside but not via the public address from the inside.We had noted "Internal hairpin blocked" in the 2026-10 incident log, and it remained the same on 2026-11-01. The connection refusal from one external location was deemed an issue on that specific location's end since the other three locations were normal, though the exact cause was not confirmed (estimated).

We measured a few more things.

6. What Happened at TT Lab

Generalized, it comes down to this.

  1. The service domain would not open only within the office. Since external guests had no issues, we initially suspected the DNS or browser cache of the office device. In reality, the router was not doing hairpinning.
  2. Internal tests could not indicate external status. When an outage occurred where external access was genuinely broken, because it was a name that never opened properly from the inside anyway, "it doesn't work" did not provide new information. Conversely, "it works" with the internal address from the inside does not guarantee external access. Afterward, we split verification into two paths: check the inside directly via --resolve to the gateway address, and check the outside using an external monitoring service.
  3. We used different solutions for different locations. Inside the cluster, we set up split DNS using CoreDNS's hosts plugin (this is why names resolved to internal addresses in pods). For development laptops, we pinned them using the hosts file. We did not touch the router settings.
  4. There was a forgotten hosts file. One node's hosts file had the name mapped to the public addressinstead, so any connection targeting the domain from that node always failed. This is a clear demonstration of the drawback of hosts files: "they must be maintained manually per device, and they get forgotten."

If outbound traffic to its own public address is blocked from within the cluster, issues occur where cert-manager's HTTP-01 self-check fails, such as "it works from the outside, but internal verification fails." This was covered in How cert-manager Issues and Renews Certificates.

7. Limitations

8. Summary: Hearing "It only fails from the inside"

Check Reason
Open from the outside first (mobile data, external monitoring service) If it works outside but fails inside, it is likely a hairpin issue
From the inside, --resolve 이름:443:내부주소 If this works, the server is healthy and it's a routing issue
Whether it's a timeout for the public address from the inside If it's a timeout rather than a refusal, the router is either dropping or rewriting the destination
Fix it on the name resolution side Split DNS (for multiple), hosts (for a few), enable router features if available
Forgotten hosts file If only one device behaves differently, check the hosts file first
Location of the verification device "It works" measured from a cluster node might not have crossed the private network

A word from TT. If I sent a letter to my own address and the post office said, "Oh, it's the same neighborhood," and immediately replied using the next-door neighbor's name, I'd throw it away as a letter for a stranger. The post office has to reply with their official post office stamp for me to accept it. If that doesn't work, you can just shout "Next room!" from inside the house (Split DNS)!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment