§12.3
Chapter 12 · MASQUE: Proxying UDP and IP over HTTP/3

§12.3Proxying UDP in HTTP with CONNECT-UDP

RFC 9298RFC 9297

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.

Target (UDP)UDP proxyClientSETTINGS —ENABLE_CONNECT_PROTOCOL =1 · H3_DATAGRAM = 1parse target_host192.0.2.6,target_port 443 →open a UDP socketeach inner UDP packet = HTTPDatagram — [ Quarter Stream ID |Context ID 0 | UDP payload ]DF bit set — the proxy never IP-fragments the forwarded packetExtended CONNECT ·:protocol=connect-udp ·:path=/.well-known/masque/udp/192-.0.2.6/443/:status=200 · capsule-protocol=?1— tunnel open on this requeststreamHTTP Datagram (ctx 0) — innerUDP payloadsend as a UDP packet →192.0.2.6:443UDP reply (source validated)HTTP Datagram (ctx 0) — innerUDP payload
Fig. 12.3-1A CONNECT-UDP tunnel end to end. The client sends an Extended CONNECT (:protocol=connect-udp) whose path names the target; the proxy parses target_host/port, opens a UDP socket, and answers 200. Each inner UDP packet then travels as an HTTP Datagram — Quarter Stream ID, then Context ID 0, then the UDP payload — which the proxy forwards as a real UDP packet, and vice versa.RFC 9298 §3, §5

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:

L1IP·UDPUDP datagram · client → proxy:443the only IP/UDP headers on the wire — this is the OUTER connection to the proxy
02028byte offsetIP headersrc → proxy160 b · 20 BUDP header→ :44364 b · 8 BUDP Payload= one QUIC packet0..*
L2QUICOUTER QUIC 1-RTT packet · to the proxyencrypted with the client↔proxy keys; the proxy can read this layer
01911byte offsetHeaderByte0x418 b · 1 BDest. Connection IDthe proxy's CID64 b · 8 BPacket Number1-4 B16 b · 2 BFrame Payloada DATAGRAM frame0..*
L3QUICDATAGRAM frame · type 0x30RFC 9221 · the unreliable carrier — no retransmission of the inner packet
01byte offsetType0x30DATAGRAM8 b · 1 BDatagram Dataan HTTP/3 Datagram0..*
L4HTTP/3HTTP/3 DatagramRFC 9297 · Quarter Stream ID names the tunnel's request stream
01byte offsetQuarterStream0x02stream 8 ÷ 48 b · 1 BHTTP Datagram Payloadper extension0..*
L5MASQUECONNECT-UDP payloadRFC 9298 · Context ID 0 = a plain UDP packet's payload
01byte offsetContextID0x00= UDP payload8 b · 1 BUDP Proxying Payloadthe inner UDP payload0..*
L6QUICINNER QUIC packet · to the target websitethe tunneled packet · its OWN IP/UDP headers are NOT here — the proxy adds them on egress
01911byte offsetHeaderByte0x418 b · 1 BDest. Connection IDthe website's CID64 b · 8 BPacket Number1-4 B16 b · 2 BFrame Payloadinner STREAM frame0..*
L7QUICinner STREAM frame · type 0x0bencrypted with the client↔website keys — the proxy CANNOT read from here down
0123byte offsetType0x0bSTREAM8 b · 1 BStreamID0x00stream 08 b · 1 BLengthbytes8 b · 1 BStream DataHTTP/3 frames0..*
L8HTTP/3inner HTTP/3 HEADERS · the real requestthe actual GET to the website — end-to-end between client and website only
012byte offsetType0x01HEADERS8 b · 1 BLengthbytes8 b · 1 BEncoded Field SectionQPACK — the GET0..*
Fixed-width fieldVariable-length integer (varint)Length / count prefixVariable-length valueAEAD-protected / encrypted
Fig. 12.3-3QUIC tunneled through a MASQUE proxy — every layer on the wire (client → proxy)RFC 9298 §5; RFC 9297 §2.1; RFC 9221 §4

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 ID 0 = 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:

Client → Proxy
UDP datagram · opening the tunnel — an ordinary request on stream 8
1-RTT packet · short header
STREAMstream 8 · HEADERS: Extended CONNECT, :protocol = connect-udp, target in :path
Proxy → Client
UDP datagram · tunnel open — stream 8 stays open for its whole life
1-RTT packet · short header
ACKSTREAMstream 8 · HEADERS: 200 + capsule-protocol: ?1 — from here on, stream 8's DATA frames are the capsule channel (none needed for plain UDP)
Client → Proxy
UDP datagram · a tunneled packet — on no stream at all
1-RTT packet · short header
DATAGRAMQuarter Stream ID 2 (= 8 ÷ 4) · Context ID 0 · the DNS query bytes — the proxy strips both varints and sends a real UDP packet to the target
Proxy → Client
UDP datagram · the reply, re-wrapped the same way
1-RTT packet · short header
DATAGRAMQuarter Stream ID 2 · Context ID 0 · the DNS reply bytes — QSID 2 is what ties this connection-level datagram back to stream 8's tunnel
packets1-RTT
framesACKSTREAM (app data)DATAGRAM
Fig. 12.3-4A CONNECT-UDP tunnel's whole life — everything keyed off request stream 8RFC 9298 §3.4, §5; RFC 9297 §2.1

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.

L1QUIC1-RTT packet · short headereverything after the header is AEAD-encrypted on the wire
01911byte offsetHeaderByte0x418 b · 1 BDest. Connection ID8 B (example)64 b · 8 BPacket Number1-4 B16 b · 2 BFrame PayloadSTREAM frame0..*
L2QUICSTREAM frame · type 0x0a — LEN set, no FINno FIN: the tunnel lives exactly as long as this stream stays open
0124byte offsetType0x0a8 b · 1 BStreamID0x08stream 88 b · 1 BLength0x40 0x6avarint 10616 b · 2 BStream DataHTTP/3 frames0..*
L3HTTP/3HEADERS frame · type 0x01Type + Length + QPACK payload (RFC 9114 §7.1)
013byte offsetType0x01HEADERS8 b · 1 BLength0x40 0x67varint 10316 b · 2 BEncoded Field SectionQPACK0..*
L4QPACKfield section · 103 bytes — the request itselfstatic table + literals; no dynamic table, no Huffman
0234195982byte offsetPrefix00 00RIC=0 · Base=016 b · 2 B:method:CONNECT0xcfidx 158 b · 1 B:scheme:https0xd7idx 238 b · 1 B:authority: proxy.example50 0d 70…name 0 · 15 B120 b · 15 B:path: /.well-known/…/443/51 26 2f…name 1 · 40 B320 b · 40 B:protocol: connect-udp27 02 3a…literal · 23 B184 b · 23 Bcapsule-protocol: ?127 09 63…literal · 21 B168 b · 21 B
Fixed-width fieldVariable-length integer (varint)Length / count prefixVariable-length valueAEAD-protected / encrypted
Fig. 12.3-5The Extended CONNECT of flight 1, byte for byteRFC 9298 §3.4; RFC 9204 §4.5, Appendix A; RFC 9000 §19.8
Note

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.

In practice

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.