chaowen.← Back to archive

Networking / transport protocol deep dive

QUIC connection migration: how one encrypted connection survives an IP change

TCP identifies a connection by a four-tuple. QUIC adds a stable connection identity and then proves that a new network path belongs to the same peer. That small change lets a phone move from Wi-Fi to cellular without throwing away a live HTTP/3 request.

Networking / transport protocol deep dive · QUIC v1, connection IDs, path validation, NAT rebinding, and implementation failure modes

2026-08-25

The concrete problem

A TCP connection is identified by the tuple (source IP, source port, destination IP, destination port). If a laptop changes from Wi-Fi to 5G, its source address usually changes. The server sees a different tuple and the old TCP connection is no longer the socket it knows. Applications must reconnect, repeat authentication, and often replay a request.

Mobile and edge workloads make that expensive. A browser may have a large response in flight, a video stream may have a congestion window already tuned, and an API request may be waiting behind a TLS handshake. QUIC runs TLS 1.3 inside a UDP-based transport and deliberately separates connection identity from the current network path.

Core invariant: an address is where a packet came from; a connection ID is which QUIC connection the packet claims to belong to. A new address is accepted only after the endpoint proves it can receive traffic at that address.

The whole data and control flow

Suppose a client has a live connection to server.example. Its Wi-Fi path is 192.0.2.10:40000 → 203.0.113.7:443. The client then moves to cellular and sends from 198.51.100.22:51000.

existing QUIC connection TLS keys + stream state + packet number space destination Connection ID: CID-A │ │ packet arrives from a new source address ▼ lookup by CID-A ──► existing connection, not a new UDP flow │ ├─► create candidate path for 198.51.100.22:51000 ├─► send PATH_CHALLENGE with unpredictable 8-byte data │ │ │ └─► peer returns PATH_RESPONSE(data) │ └─► mark path validated; use it for normal packets old path remains usable until the implementation switches or expires it application streams do not restart; congestion/loss bookkeeping is adjusted
  1. Recognize. The receiver decrypts the packet header and uses its destination connection ID to find the existing connection. The source address is new, but the connection is not.
  2. Probe. The receiver associates the new address with a candidate path and sends a PATH_CHALLENGE. The challenge is not an application secret; it is proof that the peer can receive and return a packet on that path.
  3. Validate. The peer sends a PATH_RESPONSE containing the challenge data. An off-path spoofing attacker cannot normally observe the challenge and therefore cannot complete validation.
  4. Switch. Once policy permits, normal stream packets use the validated path. Stream offsets, TLS keys, connection IDs, and the logical request remain continuous.
  5. Retire. The old path may drain briefly, then becomes inactive. Implementations must bound retained paths and connection IDs so an attacker cannot create unbounded state.

Connection IDs are the indirection layer

Every QUIC packet has a connection ID in its header when the negotiated packet type uses one. The server provides connection IDs with NEW_CONNECTION_ID; each supplied ID has a sequence number and a stateless-reset token. The client can choose a fresh destination ID when it migrates, which helps the server route packets without exposing the old five-tuple.

This is not the same as changing a connection ID in place. The ID is a label that lets a server find connection state. A load balancer can route by that label, while the QUIC endpoint tracks the peer's current address and validates it separately. The connection ID does not authenticate the sender and does not prove that the new path is usable.

ThingWhat it answersWhat it does not answer
Connection IDWhich QUIC connection should receive this packet?Can the peer receive traffic at this source address?
Source addressWhich network path delivered this packet?Whether the packet was sent by the legitimate peer.
PATH_CHALLENGE / RESPONSECan the peer return a token on this path?Whether the path is fast, private, or free of an active attacker.
Packet number and ACKWhich encrypted packets arrived and how long delivery took?Whether the path should be trusted without address validation.

Intentional migration versus NAT rebinding

There are two superficially similar events.

Intentional migration is normally initiated by a client that knows its local network changed. It can use a new local address and a connection ID reserved for the new path. QUIC v1 does not let a server arbitrarily migrate a client by sending from a new server address; server-side migration requires later protocol extensions and explicit support.

NAT rebinding happens without the application deciding to move. A NAT may replace the client's public source port after an idle timeout, so the server sees a new source tuple. If the packet still carries a valid connection ID and decrypts correctly, the server can treat it as a candidate path and validate it. This is why a QUIC connection can survive a home router changing its external port.

The distinction matters for policy. A client can intentionally probe and switch when validation succeeds. A server receiving a packet from an unfamiliar address must be conservative: answer the challenge, avoid amplifying traffic to an unvalidated address, and do not let repeated spoofed addresses consume unlimited memory.

What lives in an implementation

The following is the shape of the mechanism in the open-source quic-go implementation. Names are useful for reading code, but they are not a stable public API.

Module or structRole in the flow
pathStores a candidate path's address, last packet time, challenge bytes, validation state, and whether non-probing traffic was received.
pathManager.HandlePacketFinds or creates a path, enqueues a challenge for a new address, returns a response when needed, and decides whether a validated path may become active.
pathManager.HandlePathResponseFrameMatches response data to the stored challenge and marks the path validated.
connection.goConnects packet parsing to path handling and updates the active transport path when migration is accepted.
path_manager_outgoing.goHandles the local decision to add, probe, and switch to another transport path.
internal/wireEncodes and decodes wire-level frames such as PATH_CHALLENGE and PATH_RESPONSE.

One implementation detail is especially instructive: quic-go keeps at most maxPaths = 3 live path records and uses a five-second inactivity threshold before evicting an old one. Those constants are an implementation choice, not a QUIC wire limit, but they show the security shape of the problem: path migration is a state machine with an eviction policy, not just an address comparison.

Why validation prevents amplification

UDP permits source-address spoofing. If a server responded with a large amount of data to every apparent new address, an attacker could use the server as an amplifier. QUIC therefore limits what an endpoint sends to an unvalidated address. A small challenge and carefully bounded responses are safer than immediately sending a full stream response.

The challenge also establishes reachability in the direction that matters. The endpoint that receives PATH_RESPONSE knows the peer received the challenge on the candidate path and could return the token. It does not prove that every packet from that address is benign, so congestion control, packet authentication, rate limits, and connection-state bounds still matter.

Security boundary: packet encryption authenticates packets to the connection, while path validation authenticates reachability of an address. Neither mechanism makes the network path confidential from observers, nor does either eliminate denial-of-service risk.

Transport state after the move

Migration preserves the logical connection, but it does not make the new path identical to the old one.

Failure modes that look like application bugs

How to debug a migration in production

  1. Log a redacted connection identifier, path identifier, local and remote address family, path state, validation result, and switch timestamp. Never log TLS secrets or full tokens.
  2. Separate the events packet seen on new address, challenge sent, response received, and active path switched. A “migration failed” counter hides which transition failed.
  3. Compare packet number, ACK delay, RTT, loss, congestion window, and PMTU before and after the transition. A successful switch can still create a throughput collapse if recovery state is wrong.
  4. Capture packets at both endpoints when possible. Look for the same connection ID, a valid encrypted packet from the new tuple, a challenge, the matching response data, and subsequent application packets on the new path.
  5. Test NAT rebinding separately from intentional migration. In the first case the peer may not know it moved; in the second case the implementation can schedule probing and switching explicitly.

Compact reproducible lab: read the real migration state machine

Goal: run quic-go's self-tests for intentional migration and NAT rebinding. The test uses real QUIC packet processing and verifies that a path is probed, validated, and then selected without restarting the connection.

Prerequisites: Git, Go 1.22 or newer, and network access for the first dependency download. Run it in a disposable directory.

LAB=$(mktemp -d /tmp/quic-go-migration.XXXXXX)
git clone --depth 1 https://github.com/quic-go/quic-go.git "$LAB/quic-go"
cd "$LAB/quic-go"

# End-to-end connection migration over local transports.
go test ./integrationtests/self -run 'TestConnectionMigration' -count=1 -v

# Read the smaller path-manager cases, including NAT rebinding.
go test . -run 'TestPathManager(NATRebinding|IntentionalMigration)' -count=1 -v

Expected observation: all three tests pass. The integration test exercises Path.Probe and refuses Path.Switch before validation; the path-manager tests check that the response data matches the stored challenge and that NAT rebinding does not switch the active path too early.

=== RUN   TestConnectionMigration
--- PASS: TestConnectionMigration (...s)
=== RUN   TestPathManagerIntentionalMigration
--- PASS: TestPathManagerIntentionalMigration (...s)
=== RUN   TestPathManagerNATRebinding
--- PASS: TestPathManagerNATRebinding (...s)
PASS

Revealing variations:

This lab intentionally tests a mature implementation rather than asking a generic HTTP client to “show migration.” The curl installed on this machine has HTTP/2 but no HTTP/3 support, so a one-line curl demonstration would not exercise QUIC at all.

The design in one sentence

QUIC makes mobility possible by replacing tuple identity with connection-ID routing, then adds a cryptographic reachability check before trusting a new path. The difficult engineering work is everything around that sentence: managing spare IDs, preventing amplification, bounding path state, preserving stream and recovery semantics, and observing each state transition well enough to debug a phone that changed networks without losing its request.

Primary sources