$RodHat_
MOTD

Your curl switched to HTTP/3 without asking. The packet trace shows encrypted UDP.

Published by

Your curl switched to HTTP/3 without asking. The packet trace shows encrypted UDP.
Photo: AI-generated, no human photographer / RodHat AI Cover

curl has had HTTP/3 support since 2021. For years it required a separate libcurl build against ngtcp2 or quiche, and your distro’s package manager didn’t care to maintain either one. Gentoo users had it. Everyone else was on HTTP/2 and didn’t notice.

That changed. Debian 13, Ubuntu 24.04, and their descendants now ship curl compiled with HTTP/3 support enabled by default. The support is compiled in, the Alt-Svc negotiation is automatic, and if the server you’re talking to advertises h3=":443" in a response header, your next connection goes over QUIC. You didn’t opt in. You also can’t easily tell when it happens.

The negotiation is invisible

The first HTTPS connection to a host works the same way it always has: TLS over TCP, usually HTTP/2 if the server supports it. The response comes back with an Alt-Svc header:

Alt-Svc: h3=":443"; ma=86400

That’s the server saying it speaks HTTP/3 on port 443 and you should remember that for the next 86400 seconds. On subsequent requests to the same host, a curl compiled with HTTP/3 support will attempt the QUIC handshake first. If it works, you’re on UDP. The TLS is still there (QUIC requires TLS 1.3 internally and there’s no way around that), but the transport-layer view looks nothing like what you expect.

Check what you’re actually running:

curl -V | grep -i quic
curl -sv https://cloudflare.com 2>&1 | grep -i 'alt-svc\|h3\|quic'

If you see ngtcp2 or quiche in curl -V output, HTTP/3 negotiation is live. Run the second command; if Alt-Svc: h3=":443" shows up in the response, the next request to that host goes over QUIC.

tcpdump sees UDP on 443. The payload is opaque.

tcpdump over a QUIC session gives you this: source address, destination address, UDP port 443, packet size, timing. That’s it. The payload is encrypted and the packet structure tells you nothing useful unless you know what to look for.

I have been using tcpdump since SunOS 4. I know its flags reflexively. That reflex is now half as useful for HTTPS traffic, because half of it may be running over a protocol where wire visibility ends at the IP header.

Wireshark decrypts QUIC if you have the session keys. Set SSLKEYLOGFILE before the request, load the file into Wireshark, and you get the full picture: QUIC frames, stream IDs, HTTP/3 headers, response body. The mechanism is the same as TLS 1.3 decryption:

export SSLKEYLOGFILE=/tmp/tls_keys.log
curl https://example.com

# Wireshark: Edit → Preferences → Protocols → TLS
# → (Pre)-Master-Secret log filename → /tmp/tls_keys.log

It works. It’s also five steps instead of one. “Just run tcpdump” is not the answer anymore for HTTPS debugging on a host sending QUIC. Know the workaround before you need it.

For forcing protocol: curl --http3 requires HTTP/3, fails if it can’t get it. curl --http2 forces HTTP/2 over TLS/TCP. curl --http1.1 goes back to 1995. You can test each path explicitly without touching your config.

What QUIC actually fixes (and I don’t say this often)

HTTP/2 multiplexes streams over a single TCP connection. TCP is a reliable, ordered byte stream. Drop one packet on stream 3 and TCP stalls the entire connection waiting for the retransmit: stream 1, stream 2, stream 4 all wait. HTTP-level multiplexing, transport-level serialization. Better than HTTP/1.1, still not free.

QUIC runs on UDP and implements its own reliability per-stream. A dropped packet stalls only the stream it belongs to. The others keep going. On lossy paths (mobile networks, cross-continental connections, anything with real packet loss above 0.1%), this is a genuine latency improvement. The math is in RFC 9000 and the measurements are in the deployment data from the major CDNs that have been running QUIC since 2019.

Connection migration also matters. TCP’s connection lives in the 5-tuple (src IP, src port, dst IP, dst port, protocol). Change your IP, WiFi to cellular for example, and the TCP connection dies. QUIC has a connection ID orthogonal to the 5-tuple; the connection can migrate when your address changes. For long file transfers and streaming API connections this is the difference between a transparent handoff and a restart.

The tradeoff is what it costs you: debuggability and middleware compatibility. Firewalls that don’t expect UDP/443 will drop QUIC traffic. Middleboxes that do packet inspection see nothing useful. DPI appliances that your security team spent money on may have opinions about UDP on 443 that cause silent failures. The client falls back to TCP eventually (that’s the designed behavior), but the failure mode is “connection attempt, timeout, downgrade” which looks like transient network issues until you understand the mechanism.

What to actually do about this

Audit your monitoring scripts and health checks. If you’re curling an endpoint in a cron job and expecting to parse the timing from -v output, check which protocol you’re actually on. Add --http2 explicitly if you want stable TCP-based connection semantics and don’t need QUIC’s benefits on an internal LAN that doesn’t have meaningful packet loss.

Check your firewalls. If UDP/443 inbound is blocked at your perimeter, HTTP/3 silently fails and falls back. That’s fine until you’re wondering why a specific host is slower than expected from certain networks. The fallback is designed, but “why is this path 200ms slower” is an annoying question to debug when the answer is “QUIC is blocked, TCP fallback has higher latency because of the initial retry.”

Set up the decryption workflow now. SSLKEYLOGFILE plus a Wireshark profile configured with the key log path takes ten minutes. Do it when nothing is on fire.

If you want to understand what’s actually happening at the packet level: Stevens’ TCP/IP Illustrated covers UDP thoroughly: the underlying mechanics that QUIC runs on top of haven’t changed, the transport design above UDP is what’s new. RFC 9000 itself is readable as RFCs go; the design rationale sections explain why each tradeoff was made rather than just specifying the wire format.

The protocol is correct. QUIC solves a real problem in a reasonable way. The debugging workflow is different, and you should know it before you need it.

See also: WireGuard in the kernel: six years of UDP as secure transport for the prior art on “UDP plus your own crypto is actually right,” and ss and TCP internals for the socket diagnostics that still work cleanly on the TCP side of your traffic.

Sources

  1. curl's HTTP/3 support documentation (curl project)
  2. RFC 9000: QUIC, A UDP-Based Multiplexed and Secure Transport (IETF)