Learning objective. Understand the connection-ID machinery that makes migration possible and private: how endpoints issue a pool of connection IDs with NEW_CONNECTION_ID, retire old ones with RETIRE_CONNECTION_ID, and why each network path MUST use a fresh, previously unused connection ID.
Each endpoint names the other #
A connection has a whole set of connection IDs, and the ownership is crossed over: "each endpoint selects the connection IDs that its peer uses" ([RFC 9000 §5.1]). The server tells the client which connection IDs to put in the packets it sends, and vice versa. The connection ID's job is exactly the one migration depends on: "to ensure that changes in addressing at lower protocol layers (UDP, IP) do not cause packets... to be delivered to the wrong endpoint" ([RFC 9000 §5.1]). A packet arriving from a brand-new IP still routes to the right connection because its connection ID is unchanged.
For migration to work, though, an endpoint needs more than the one connection ID from the handshake: it needs a spare, unused one to move to. That is what the connection-ID frames maintain.
Issuing and retiring connection IDs #
Two frames maintain the pool:
- NEW_CONNECTION_ID (
0x18) "provide[s] its peer with alternative connection IDs that can be used to break linkability when migrating" ([RFC 9000 §19.15]). Each carries a sequence number (which "MUST increase by 1", [RFC 9000 §5.1.1]), the connection ID itself, and a stateless reset token for that ID. The number of connection IDs an endpoint will hold is capped by the peer'sactive_connection_id_limit(0x0e, default 2, minimum 2, [RFC 9000 §18.2],constants.md§6); issuing more than the peer allows, or holding more than you advertised, is aCONNECTION_ID_LIMIT_ERROR([RFC 9000 §5.1.1]). - RETIRE_CONNECTION_ID (
0x19) tells the peer "it will no longer use a connection ID that was issued" ([RFC 9000 §19.16]). It "also serves as a request to the peer to send additional connection IDs" ([RFC 9000 §5.1.2]), so the pool refills as it drains. It carries just the sequence number of the ID being retired.
The issuer can also force retirement: a NEW_CONNECTION_ID frame carries a Retire Prior To field, and on receiving an increased value "the peer MUST stop using the corresponding connection IDs and retire them with RETIRE_CONNECTION_ID frames" ([RFC 9000 §5.1.2]). That is how an endpoint rotates its peer off old connection IDs: for load balancing, or to refresh identifiers.
The linkability rule: never reuse a CID across paths #
The pool serves more than reachability; it also serves privacy. If a client kept the same connection ID when it moved from Wi-Fi to cellular, a passive observer watching both networks could see the same identifier on both and conclude they are the same device and session, linking the user across networks. QUIC forbids this ([RFC 9000 §9.5]):
"An endpoint MUST NOT reuse a connection ID when sending from more than one local address — for example, when initiating connection migration... or when probing a new network path."
So migration is a paired operation: change your address and change the connection ID you send to, drawing a fresh one from the pool. An observer on the new path sees a connection ID it has never seen before, unlinkable to the old path. This is also why a peer that chose a zero-length connection ID is a poor migration partner: with no connection ID to vary, "traffic over the new path might be trivially linkable to traffic over the old one" ([RFC 9000 §9.5]), so an endpoint "SHOULD NOT initiate migration with a peer that has requested a zero-length connection ID."
Provision the pool before it is needed #
The consequence for deployment is blunt: "An endpoint that exhausts available connection IDs cannot
probe new paths or initiate migration, nor can it respond to probes or attempts by its peer to migrate"
([RFC 9000 §9.5]). Because migration is triggered by an external event (the network changing at an
unpredictable moment), the spare connection ID must already be in hand. So endpoints "SHOULD provide new
connection IDs before peers migrate" ([RFC 9000 §9.5]), and a migrating endpoint "SHOULD ensure that the
pool of connection IDs available to its peer allows the peer to use a new connection ID on migration"
([RFC 9000 §5.1.1]). A server's advertised preferred_address (§10.1) even embeds a
connection ID with sequence number 1 precisely so the client is guaranteed one unused ID to migrate with
([RFC 9000 §18.2]).
Worked example: rotating IDs across a network change #
Follow Figure 10.2-1. Just after the handshake, with active_connection_id_limit at 4, the server
issues NEW_CONNECTION_ID frames for sequence numbers 1 and 2, each with its own stateless reset token:
the client now holds spare IDs 1 and 2 while still using ID 0 from the handshake. The network then
changes. The client migrates: it starts sending from its new address using connection ID 1 (never
the ID 0 it used on the old path), so an observer on the new network sees an identifier with no visible
tie to the old one. The client sends RETIRE_CONNECTION_ID for sequence 0, finished with it; the server,
seeing the pool drain, issues a fresh NEW_CONNECTION_ID for sequence 3 to keep spares available for the
next migration. Rotate, retire, replenish, and never send one ID from two addresses.
The stateless reset token attached to each connection ID is a separate resilience mechanism worth naming. If a server loses all state for a connection (a crash, a restart), it can no longer decrypt the connection's packets, but it can send a stateless reset: a packet ending in the token the peer associates with the connection ID it is using ([RFC 9000 §10.3]). The peer recognizes the token and tears the connection down cleanly instead of waiting out an idle timeout. Each connection ID carries its own token, issued alongside it in NEW_CONNECTION_ID, so a reset works for whichever ID the peer currently sends to.
editorial Treat the connection-ID pool as a migration prerequisite,
not an afterthought. On the server, set active_connection_id_limit above the minimum (4–8 is common) and
issue spare connection IDs promptly after the handshake, so a mobile client always has one ready when its
network flips. On the client, actually use a fresh ID on migration. Some naive implementations reuse the
handshake ID and silently defeat the unlinkability the design provides. And if you deploy behind a
connection-ID-aware load balancer, remember the server encodes routing into its connection IDs, so
retirement and rotation interact with how the balancer steers packets. Coordinate the two or a rotated ID
can land at the wrong backend.
Takeaways #
Migration rests on a pool of connection IDs: NEW_CONNECTION_ID issues spares (with sequence numbers and
stateless reset tokens) up to the peer's active_connection_id_limit, and RETIRE_CONNECTION_ID drains old
ones while requesting refills. The binding rule is privacy-driven: never send one connection ID from two
addresses. So migrating means changing address and connection ID together, which is why the pool must be
provisioned before the network changes. How this machinery absorbs an involuntary address change (a NAT
rebinding the client never intended) is §10.3.