Learning objective. See how CONNECT-UDP tunnels UDP through an HTTP/3 proxy end to end: the Extended CONNECT request that names the target, the 2xx that opens the tunnel, and the Context-ID encoding that turns each inner UDP payload into an HTTP Datagram.
Opening a UDP tunnel with Extended CONNECT #
Ordinary CONNECT tunnels TCP; CONNECT-UDP ([RFC 9298]) tunnels UDP, and it does so by extending
the CONNECT method with a :protocol pseudo-header: the Extended CONNECT mechanism (enabled in
HTTP/3 by SETTINGS_ENABLE_CONNECT_PROTOCOL = 0x08, [RFC 9220], constants.md §11). The request that
opens a tunnel is an HTTP/3 request with four telling pseudo-headers ([RFC 9298 §3.4]):
:method = CONNECT
:protocol = connect-udp
:scheme = https
:authority = proxy.example ← the proxy
:path = /.well-known/masque/udp/192.0.2.6/443/ ← the target
capsule-protocol = ?1
The target is encoded in the path. RFC 9298 defines a default URI Template,
/.well-known/masque/udp/{target_host}/{target_port}/ ([RFC 9298 §2]), so the proxy reads the
target_host and target_port variables straight out of the path, here 192.0.2.6 and 443
(a hostname or an IPv6 literal works too; an IPv6 target's colons are percent-encoded). This is a
request to proxy UDP to that specific destination.
The proxy opens a socket and answers 2xx #
On receiving the request the proxy "extracts the target_host and target_port variables… and
establishes a tunnel by directly opening a UDP socket to the requested target" ([RFC 9298 §3.1]). It
signals success with a status in the 2xx range and a response that starts the Capsule Protocol
([RFC 9298 §3.5]); "any response other than a successful response indicates that the request has failed;
thus, the client MUST abort the request" ([RFC 9298 §3.1]). Once 200 comes back, the proxy "commits to
converting received HTTP Datagrams into UDP packets, and vice versa, until the tunnel is closed": the
tunnel is live on that one request stream.
Encoding a UDP packet: Context ID plus payload #
Now the inner UDP packets flow as HTTP Datagrams (§12.2), and CONNECT-UDP defines what goes in the (previously opaque) HTTP Datagram Payload. Every CONNECT-UDP datagram payload begins with a Context ID ([RFC 9298 §4, §5]):
UDP Proxying HTTP Datagram Payload {
Context ID (i),
UDP Proxying Payload (..),
}
The base case is Context ID 0, which means the payload is a plain UDP packet: "when the Context ID
field is set to zero, the UDP Proxying Payload field contains the unmodified payload of a UDP packet"
([RFC 9298 §5]). So an inner UDP packet on request stream 8 is carried, over HTTP/3, as a QUIC DATAGRAM
frame whose payload is: Quarter Stream ID (8÷4 = 2) → Context ID (0) → the UDP payload bytes —
the two-varint prefix from §12.2 plus the Context ID, then the packet.
The Context ID is a format selector, not an address. It answers exactly one question for the receiver: how do I parse the rest of this datagram's payload? Zero is the one predefined answer — "the Context ID value of 0 is reserved for UDP payloads" ([RFC 9298 §4]) — meaning "the remaining bytes are an unmodified UDP payload; forward them." A future extension can register a non-zero context (the registration itself travels in a header field or a capsule, [RFC 9298 §4]) whose meaning is "payloads under this ID are in my format instead": a compressed encoding of the same packets, say. Allocation is collision-free by parity: "non-zero even-numbered Context IDs are client-allocated, and odd-numbered Context IDs are proxy-allocated" ([RFC 9298 §4]), so either side can mint one without coordinating.
Two natural misreadings are worth killing directly. First, a Context ID does not mark a datagram as
"addressed to the proxy itself". Tunnel control — address assignment, route advertisements
(§12.4) — never travels as datagrams at all; it rides capsules on the reliable request
stream (§12.2). Second, the mandatory 0x00 on plain payloads is not waste. Because
every datagram starts with a Context ID, the format is self-describing: a receiver always parses
byte one as a varint context and dispatches on it, even on a tunnel where no extension was ever
negotiated. Were the prefix optional, the first byte of a raw UDP payload would be
indistinguishable from a context number.
The third strip settles where the Context ID lives when there are no QUIC datagrams at all: inside
the DATAGRAM capsule (type 0x00), the capsule's Value is the same Context-ID-prefixed payload
([RFC 9297 §3.5]). For plain UDP proxying you only ever need Context ID 0.
Forwarding rules that keep it honest #
Two rules make the proxy safe rather than an open relay:
- Source validation on the return path. If the proxy uses a non-connected socket, it "MUST validate the IP source address and UDP source port on received packets to ensure they match the client's request. Packets that do not match MUST be discarded" ([RFC 9298 §3.1]), so a third party cannot inject packets into someone else's tunnel.
- No IP fragmentation. The proxy "MUST NOT introduce fragmentation at the IP layer when forwarding… In IPv4, the Don't Fragment (DF) bit MUST be set" ([RFC 9298 §3.1]): the same fragmentation-avoidance discipline QUIC itself follows (§9.2), because a fragmented tunneled packet is worse than a dropped one.
The inner payload with Context ID 0 also inherits UDP's own ceiling: it "MUST NOT be longer than 65527 bytes" ([RFC 9298 §5]), though in practice the usable size is bounded by the outer QUIC connection's datagram size (§9.2), since the whole thing must fit in one QUIC DATAGRAM frame.
The full stack: tunneling a QUIC connection #
The marquee use of CONNECT-UDP is carrying a whole QUIC connection through the proxy: the QUIC-in-QUIC case from §12.1. It is worth seeing every layer this puts on the wire, because the nesting is what makes the design click. Here is one packet from the client to the proxy, in the style of the "shape of a GET" from §1.3, when the inner traffic is a QUIC request to a website:
Read the stack outer to inner and the whole chapter is on one packet:
- One UDP datagram, one set of IP/UDP headers (top): the outer connection to the proxy. This is the only IP/UDP framing on the wire. The inner QUIC packet further down has no IP/UDP headers of its own here; the proxy adds fresh ones when it forwards the inner packet to the website.
- The outer QUIC packet carries a DATAGRAM frame (§9.3), unreliable, so the inner
packet is never retransmitted by the outer connection. Its data is an HTTP/3 Datagram. Its
Quarter Stream ID (
0x02) names the tunnel's request stream (§12.2), and the CONNECT-UDP payload marks Context ID0= a UDP payload. - That UDP payload is the inner QUIC packet: the tunneled packet to the website. It has its own header, connection ID, and packet number, and its frame payload is encrypted with the inner client↔website keys. From this layer down the proxy is blind: it forwards these bytes without being able to read them.
- Inside the inner packet is an inner STREAM frame carrying the inner HTTP/3 HEADERS: the actual GET the client is making to the website, end to end.
So a single tunneled request is nested five encapsulations deep — UDP → outer QUIC → DATAGRAM frame → HTTP/3 Datagram → CONNECT-UDP payload → inner QUIC → inner STREAM → inner HTTP/3 — yet the only overhead over a direct QUIC connection is the DATAGRAM frame type byte plus two varints (Quarter Stream ID and Context ID 0). The two QUIC connections are fully independent: the outer protects the tunnel, the inner is end-to-end between client and website, and the proxy sits between them able to route the datagram but not to read the request.
Worked example: DNS over the tunnel #
Trace Figure 12.3-1 for a client resolving a name through the proxy. It sends Extended CONNECT with
:path = /.well-known/masque/udp/192.0.2.6/53/; the proxy opens a UDP socket to the DNS server at
192.0.2.6:53 and returns 200. The client builds a DNS query (a normal UDP payload) and sends it as an
HTTP Datagram: Quarter Stream ID, Context ID 0, then the query bytes. The proxy strips the two prefixes
and sends the query as a genuine UDP packet from its own address to 192.0.2.6:53. The DNS reply comes
back to the proxy, which validates it came from 192.0.2.6:53, wraps it (Quarter Stream ID, Context ID 0,
reply bytes) and returns it as an HTTP Datagram. The client unwraps a normal DNS response. The DNS server
saw a query from the proxy; the network saw only HTTP/3 to the proxy.
Here is that whole lifecycle as the frames actually appear on the client↔proxy connection. Notice
what is, and is not, on a stream: the CONNECT and the 200 are HEADERS on request stream 8, and that
same stream's DATA frames are where capsules would go if any were needed. The tunneled packets
themselves ride connection-level DATAGRAM frames on no stream at all; the only thing tying each
one back to the tunnel is its leading Quarter Stream ID, 8 ÷ 4 = 2:
And flight 1 is not special: it is an ordinary HTTP/3 request, which means every layer of
§1.3 applies to it. Here is that CONNECT expanded to actual bytes — the STREAM frame,
the HEADERS frame inside it, and the QPACK-encoded field section inside that. Note the STREAM
frame's type is 0x0a, not 0x0b: the FIN bit stays clear, because closing this stream is
closing the tunnel.
CONNECT-UDP is what makes QUIC-through-a-proxy work, and the encoding is why it is efficient. Because the inner UDP packets ride QUIC DATAGRAM frames unreliably (§9.3), a tunneled QUIC connection (§12.1) sees packet loss exactly as it would on a direct path and runs its own recovery; there is no nested retransmission. The per-packet overhead is tiny: two short varints (Quarter Stream ID and Context ID 0) plus the outer QUIC/UDP/IP headers. That efficiency is what lets privacy relays carry full QUIC connections at scale.
editorial Three things trip up first implementations. First, Extended
CONNECT must be negotiated: the proxy has to advertise SETTINGS_ENABLE_CONNECT_PROTOCOL and
SETTINGS_H3_DATAGRAM, or the request or its datagrams are rejected. Second, always prefix Context ID 0
even for plain UDP; a datagram payload that is just the UDP bytes, with no Context ID, is malformed.
Third, honour source validation and the DF bit on the forwarding side; skipping validation turns your
proxy into an amplification/injection vector, and skipping DF silently corrupts large packets. Test with a
real UDP service (DNS is ideal) end to end before layering QUIC on top.
Yes, the proxy hop can be TCP: the HTTP/1.1 variant #
A reasonable objection at this point: "isn't MASQUE a QUIC thing?" HTTP/3 is the preferred and, in
deployment, dominant substrate — it is the only carrier with real unreliable datagrams, which is what
avoids nested retransmission for tunneled QUIC (§12.1). But the carrier hop is
deliberately version-agnostic, and RFC 9298 defines the TCP-based variants explicitly: over HTTP/2 the
request is the same Extended CONNECT, while over HTTP/1.1 (which has no :protocol pseudo-header) the
request "SHALL be GET" with an Upgrade, mimicking WebSocket ([RFC 9298 §3.2]). On the wire it is
plain ASCII — a conforming request to the same target looks like this:
GET /.well-known/masque/udp/192.0.2.6/443/ HTTP/1.1
Host: example.org
Connection: Upgrade
Upgrade: connect-udp
Capsule-Protocol: ?1
The proxy accepts with 101 Switching Protocols (plus the same Upgrade and Capsule-Protocol
fields, [RFC 9298 §3.3]), and from the blank line after that response onward, the raw TCP connection
is the tunnel's data stream ([RFC 9297 §3.1]). With no QUIC underneath there are no DATAGRAM
frames and no Quarter Stream ID; each tunneled packet instead travels as a DATAGRAM capsule
whose Value is the same Context-ID-prefixed payload — the third strip of Figure 12.3-2. The price
is that the tunnel is now reliable and ordered (TCP retransmits stale packets, §12.2),
and the whole HTTP/1.1 connection is consumed by this one tunnel. Which is the argument for HTTP/3
in one sentence: same request shape, same Context-ID data model, but only QUIC lets the tunneled
packets keep datagram semantics.
Here is the DNS query of Figure 12.3-4 leaving the client on each of the three carriers. Over
HTTP/2 the tunnel opens with the same Extended CONNECT as HTTP/3 ([RFC 9298 §3.4]), just on a TCP
stream; on both TCP carriers the data plane falls back to DATAGRAM capsules, and the same
fallback serves an HTTP/3 peer that never negotiated SETTINGS_H3_DATAGRAM (§12.2):
Takeaways #
CONNECT-UDP opens a UDP tunnel with an Extended CONNECT request whose :protocol is connect-udp and
whose path (/.well-known/masque/udp/{host}/{port}/) names the target; a 2xx opens it on that request
stream. Each inner UDP packet is carried as an HTTP Datagram whose payload is a Context ID (0 for a plain
UDP packet) followed by the UDP bytes, prefixed, over HTTP/3, by the Quarter Stream ID. The proxy
validates return-path sources and never IP-fragments. Tunneling IP rather than UDP (a full VPN, with
address assignment and routes) is §12.4.