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.
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.
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.
- 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.
- 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. - Validate. The peer sends a
PATH_RESPONSEcontaining the challenge data. An off-path spoofing attacker cannot normally observe the challenge and therefore cannot complete validation. - Switch. Once policy permits, normal stream packets use the validated path. Stream offsets, TLS keys, connection IDs, and the logical request remain continuous.
- 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.
| Thing | What it answers | What it does not answer |
|---|---|---|
| Connection ID | Which QUIC connection should receive this packet? | Can the peer receive traffic at this source address? |
| Source address | Which network path delivered this packet? | Whether the packet was sent by the legitimate peer. |
| PATH_CHALLENGE / RESPONSE | Can the peer return a token on this path? | Whether the path is fast, private, or free of an active attacker. |
| Packet number and ACK | Which 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 struct | Role in the flow |
|---|---|
path | Stores a candidate path's address, last packet time, challenge bytes, validation state, and whether non-probing traffic was received. |
pathManager.HandlePacket | Finds 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.HandlePathResponseFrame | Matches response data to the stored challenge and marks the path validated. |
connection.go | Connects packet parsing to path handling and updates the active transport path when migration is accepted. |
path_manager_outgoing.go | Handles the local decision to add, probe, and switch to another transport path. |
internal/wire | Encodes 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.
Transport state after the move
Migration preserves the logical connection, but it does not make the new path identical to the old one.
- Streams continue by offset. The receiver still knows which bytes have arrived and which are missing. A stream does not restart at byte zero just because its packets changed source address.
- Packet numbers remain connection state. ACKs and loss detection must be interpreted consistently. A packet number is not reset merely because the path changed.
- RTT and congestion need care. Wi-Fi and cellular can have radically different delay, bandwidth, and loss. Carrying an old congestion estimate can cause a burst; resetting too aggressively wastes available capacity. Implementations must follow the recovery specification and their own tested policy.
- PMTU may change. A path that carried 1,400-byte packets may not support the same size through a tunnel or mobile carrier. Path MTU discovery and black-hole handling must not assume the old route.
- Datagrams are disposable. Unreliable QUIC DATAGRAM frames can be lost during a move. Applications using them must tolerate loss instead of treating migration as a reliable handoff.
Failure modes that look like application bugs
- No spare connection ID. A peer may not have issued enough IDs for future paths. The connection can remain alive, but a migration strategy that requires a new ID may fail or become more linkable.
- Challenge sent, response lost. The candidate path stays unvalidated. Retransmission and timeout behavior are part of the path state machine; switching immediately would create an address-spoofing and amplification hazard.
- Path churn. A malicious peer or spoofed traffic can present many source addresses. Bound path records, expire inactive candidates, and retire connection IDs deliberately.
- Load balancer routing mismatch. If a front-end routes only by the five-tuple, a valid migrated packet may reach the wrong worker. QUIC-aware routing must use the connection ID or a consistent connection-state layer.
- Privacy leakage. Reusing a globally stable connection ID can let an observer link the same client across networks. Rotating IDs helps, but timing and packet-size patterns can still correlate sessions.
- Broken UDP assumptions. Firewalls, NATs, VPNs, and mobile carriers may drop unfamiliar UDP traffic or rewrite ports frequently. A transport can be protocol-correct and still fail because the path never permits the probe.
How to debug a migration in production
- 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.
- Separate the events packet seen on new address, challenge sent, response received, and active path switched. A “migration failed” counter hides which transition failed.
- 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.
- 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.
- 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:
- Run
go test . -run 'TestPathManagerLimits' -count=1 -v. This exposes the eviction and path-count policy: correctness includes refusing unbounded state, not just accepting a valid response. - Open
path_manager.gobeside the tests and temporarily change the validation or eviction condition in a local branch. The tests should fail at the exact transition that protects against premature switching or path churn. Restore the checkout afterward; do not experiment in a production dependency.
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
- RFC 9000, QUIC transport: connection migration — connection IDs, path validation, NAT rebinding, and migration rules.
- RFC 9000, address validation — why a new path must not be trusted or amplified immediately.
- RFC 9002, loss detection and congestion control — RTT, ACK, loss, and congestion state that migration must preserve or adjust.
- RFC 9001, using TLS to secure QUIC — the TLS 1.3 packet-protection layer carried across the logical connection.
- quic-go path_manager.go — candidate-path state, challenges, validation, and bounded eviction.
- quic-go connection migration test — a runnable example of probing and switching paths.