Learning objective. Understand how a client and server agree on a QUIC version: the invariant version field every packet carries, the Version Negotiation packet for the incompatible case, and the modern compatible negotiation that upgrades within the first flight, plus how both are protected against downgrade.
The one field every QUIC version shares #
Almost nothing about QUIC is guaranteed across versions, but a tiny invariant core is ([RFC 8999]). Every long-header packet carries a 32-bit Version field, and the connection-ID fields around it have a fixed shape, so any endpoint, even one that doesn't speak the version, can read the version and the connection IDs ([RFC 8999 §5.1]). Two version values are special:
0x00000001is QUIC v1 ([RFC 9000 §15]);0x6b3343cfis QUIC v2 ([RFC 9369 §3.1]) (constants.md§7a).0x00000000is reserved: it marks a Version Negotiation packet ([RFC 8999 §5.4, §6]).
Because the version field is invariant, version negotiation can happen before any version-specific parsing — the server reads the client's version from the first Initial and decides whether it can proceed.
The incompatible case: a Version Negotiation packet #
If the server does not support the client's chosen version, it replies with a Version Negotiation packet listing the versions it does support ([RFC 9000 §6.1]):
The Version Negotiation packet has its Version field set to 0 and carries a list of Supported Version values ([RFC 8999 §6]). It is stateless (the server retains no state) and correspondingly not authenticated, which is the important caveat: an on-path attacker could forge one. RFC 9000 acknowledges this directly, noting the packet has no built-in downgrade protection and that a client "MUST discard a Version Negotiation packet that lists the QUIC version selected by the client" and MUST ignore VN packets once it has processed any other packet ([RFC 9000 §6.2]). A client that acts on one abandons its attempt and restarts with a version from the list — costing a full round trip.
The compatible case: negotiate inside the first flight #
The Version Negotiation packet's round-trip cost and lack of authentication motivated Compatible Version Negotiation ([RFC 9368]). The idea: two versions are compatible if a first flight of one "can [be converted] into a first flight" of the other ([RFC 9368 §2.2]), so a server that receives a client's first flight in version A but prefers a compatible version B can just continue the handshake in B — no VN packet, no extra round trip ([RFC 9368 §2.3]).
The mechanism is a transport parameter, version_information (0x11), that each side sends in
the handshake ([RFC 9368 §3]). It contains:
- a Chosen Version, the version the sender is using; and
- a list of Available Versions: for the client, "all the versions that this first flight is compatible with, ordered by descending preference"; for the server, the versions its deployment offers ([RFC 9368 §3]).
Because this rides in the authenticated handshake (§7.1), it does what the VN packet cannot: prevent downgrades.
Downgrade prevention #
The version_information exchange is validated on both ends ([RFC 9368 §4]):
- The server "MUST validate that the client's Chosen Version matches the version in use for
the connection"; a mismatch is a
VERSION_NEGOTIATION_ERROR(0x11,constants.md§7). - The client, if the server's Chosen Version "was not sent by the client as part of its Available Versions," MUST close with the same error.
An attacker who forges a Version Negotiation packet to force a weaker version therefore fails: the
authenticated version_information in the eventual handshake won't match what an untampered
negotiation would have produced, and one side detects it. Clients also "MUST ignore any received
Version Negotiation packets that contain the Original Version" ([RFC 9368 §4]).
GREASE versions #
To keep the negotiation machinery exercised, versions matching the pattern 0x?a?a?a?a are
reserved and will never be selected ([RFC 9000 §15], constants.md §7a). A client may advertise
one (in its available_versions list or even as its chosen version) specifically to check that
the server correctly ignores unknown versions or triggers negotiation, rather than mishandling
them. It is the version-layer analogue of the GREASE frame types and settings seen elsewhere
(§5.1).
Worked example: a v1→v2 upgrade with no round trip #
Follow the two paths in Figure 7.4-1. In the incompatible path, the client sends an Initial with
an unsupported version; the server returns a Version Negotiation packet (Version 0) advertising
0x00000001 and 0x6b3343cf; the client picks one and starts a fresh handshake: one wasted
round trip. In the compatible path, the client sends its first flight in v1 but includes
version_information with chosen = v1, available = [v2, v1]. The server supports v2 and knows
v1's flight is compatible, so it simply continues the handshake in v2 and echoes
chosen = v2, available = [v2, v1]. The client checks that v2 was in the list it advertised
(it was) and proceeds. The upgrade cost nothing, and any tampering would have tripped
VERSION_NEGOTIATION_ERROR.
The unauthenticated Version Negotiation packet and the authenticated version_information
transport parameter solve different halves of the problem. The VN packet is a discovery tool for
the case where the server can't parse the client's version at all. It is necessarily stateless and
unauthenticated because there is no shared secret yet. Compatible negotiation is an optimization
and hardening for versions the server can parse, moving the decision into the authenticated
handshake. A deployment supporting v1 and v2 uses the second and rarely needs the first.
editorial Don't hand-roll version handling: enable your
library's compatible-negotiation support and let it manage version_information. The failure to
watch for is a middlebox or a peer that mishandles an unknown version field: if you plan to deploy
a new version, test with a GREASE version first to confirm both your stack and the paths you care
about either negotiate or cleanly ignore it, rather than dropping the connection. And treat a
VERSION_NEGOTIATION_ERROR in logs as a possible downgrade attempt rather than a routine config
mismatch. It is precisely the signal the mechanism exists to raise.
Takeaways #
Every QUIC packet carries an invariant 32-bit version field, with 0x00000000 marking the
unauthenticated, stateless Version Negotiation packet used when the server can't speak the
client's version at all. Modern deployments prefer compatible negotiation: a version_information
transport parameter carried in the authenticated handshake lets a server upgrade within the first
flight and lets both ends detect downgrade attempts via VERSION_NEGOTIATION_ERROR. GREASE
versions (0x?a?a?a?a) keep the machinery honest. With the connection's version and parameters all
chosen, §7.5 works a complete example: selecting a full parameter set for a target
network profile.