Disclosure: I used an AI tool to help draft and clarify the wording of this issue, which I reviewed before sending; but the findings and the testing are mine (not AI-generated).
Summary
curl + OpenSSL does not send TLS early data (0-RTT) over HTTP/3 (QUIC), yet reports that it did. Over HTTP/2 (TCP), however, early data works fine. I also tested curl + GnuTLS, which works fine in all cases - the issue is specific to the QUIC/OpenSSL path.
Build
curl 8.21.0 from source with --enable-ssls-export (for --ssl-sessions); OpenSSL 3.6.3; ngtcp2 1.25.0, nghttp3 1.18.0; macOS:
./configure --with-openssl --with-nghttp2 --with-ngtcp2 --with-nghttp3 --enable-ssls-export
The output of curl -V:
curl 8.21.0 (aarch64-apple-darwin24.6.0) libcurl/8.21.0 OpenSSL/3.6.3 zlib/1.2.12 libidn2/2.3.8 libpsl/0.23.3 nghttp2/1.70.0 ngtcp2/1.25.0 nghttp3/1.18.0 OpenLDAP/2.4.28/Apple
Release-Date: 2026-06-24
Features: alt-svc AsynchDNS HSTS HTTP2 HTTP3 HTTPS-proxy IDN IPv6 Largefile libz PSL SSL SSLS-EXPORT threadsafe TLS-SRP UnixSockets
Reproducing the issue (OpenSSL)
I ran curl twice against the same HTTP/3 origin. The first run just obtains an early-data-capable session ticket; the second, resumed run adds --tls-earlydata, where I expected the request to leave as early data (a 0-RTT packet in the first flight, with the response coming back ~0.5-RTT later). Exact commands:
curl --http3-only -sS -o /dev/null --ssl-sessions /tmp/c.sess --tls-earlydata <URL>
curl -vv --http3-only -sS -o /dev/null --ssl-sessions /tmp/c.sess --tls-earlydata \
-w 'early_data=%{tls_earlydata}\n' <URL>
curl's output on that second run looks exactly like early data was used and accepted:
* SSL session allows 4294967295 bytes of early data, reusing ALPN 'h3'
* [HTTP/3] server did accept 96 bytes of early data
early_data=96
But when I looked at a tcpdump of that same run, I saw the opposite. The client's first datagram is just an Initial padded to fill the ~1200-byte datagram, with no 0-RTT packet coalesced, and the server's response only arrives about one round trip after its handshake flight - i.e. not the 0.5-RTT I expected:
client > origin: quic, initial ... length 1148 # padded Initial, no 0-rtt packet
origin > client: quic, initial + handshake ... # server handshake flight
client > origin: quic, handshake + protected ... # client Finished + the request, in 1-RTT
origin > client: quic, protected ... # HTTP response, ~1 RTT later
Finally I decrypted the handshake with SSLKEYLOGFILE, and I observed that the client never even offered early data. The ClientHello does carry the PSK extension (so it really is a resumption), but it has no early_data (42) extension; there is no 0-RTT packet; and the server's EncryptedExtensions carry no early_data either. The only early_data extension anywhere in the exchange is inside the server's NewSessionTicket - its max_early_data_size, meant for a future connection.
Yet curl still prints "server did accept 96 bytes" and %{tls_earlydata} returns 96.
Expected behavior (GnuTLS, which works)
I rebuilt curl again only with GnuTLS instead of OpenSSL. The client's first datagram does carry a 0-RTT packet:
client > origin: quic, initial ... , 0-rtt ... length 325
And the correctly server replied with 0-5 RTT.
Where the output seems to come from
I'm not an expert on curl code base, but I did my best to drill down the issue.
I read the source, and it looks to me like all of this comes from libcurl's backend rather than the command-line tool - the tool seems to only print what libcurl reports. The "SSL session allows N bytes..." line is in cf_ngtcp2_on_session_reuse() (lib/vquic/cf-ngtcp2-cmn.c), which also restores the QUIC 0-RTT transport params and sets use_earlydata. The "[HTTP/3] server did accept N bytes of early data" line and the value behind %{tls_earlydata} both come from cb_ngtcp2_handshake_completed() in the same file.
Digging a little into the OpenSSL path specifically: for HTTP/2 (TCP) curl does send early data, via SSL_write_early_data() in ossl_send_earlydata() (lib/vtls/openssl.c), reached from ossl_connect() - and my tests confirm early data works fine over HTTP/2 with curl + OpenSSL. For HTTP/3 the connection takes a different path (lib/vquic/cf-ngtcp2-cmn.c, Curl_cf_ngtcp2_cmn_connect(), driving the OpenSSL SSL via ngtcp2_crypto_ossl) and never calls ossl_send_earlydata() / SSL_write_early_data(). I could not find anywhere on that HTTP/3 OpenSSL path where client early data is actually enabled or sent - which matches the ClientHello going out without the early_data extension.
A few more data points
- With curl + OpenSSL over HTTP/2 (TCP), early data works correctly - so this looks specific to the HTTP/3 path.
- I rebuilt curl the same way on Linux (also with OpenSSL) and saw the same behavior. So it isn't macOS-specific.
- I built ngtcp2 + OpenSSL example client (
osslclient), from examples/client.cc in the ngtcp2 project. It does send real early data: a 0-RTT packet in the first flight and a 0.5-RTT response. So ngtcp2 and OpenSSL can do 0-RTT here; the difference seems to be in how curl drives them.
Docs
Separately, the CURLSSLOPT_EARLYDATA documentation is still ambiguous about QUIC:
This option is supported for GnuTLS, OpenSSL, quictls and wolfSSL [..]. It works on TCP and QUIC connections using ngtcp2. [...]. This option does not work when using QUIC.
In the same paragraph it says "It works on TCP and QUIC connections" and "This option does not work when using QUIC." It needs to be corrected to state one consistent behavior.
Happy to share more details if you need.
Thanks!
I expected the following
Early data sent, just like in the GnuTLS case.
curl/libcurl version
curl 8.21.0
operating system
MacOS 15.7
Disclosure: I used an AI tool to help draft and clarify the wording of this issue, which I reviewed before sending; but the findings and the testing are mine (not AI-generated).
Summary
curl + OpenSSL does not send TLS early data (0-RTT) over HTTP/3 (QUIC), yet reports that it did. Over HTTP/2 (TCP), however, early data works fine. I also tested curl + GnuTLS, which works fine in all cases - the issue is specific to the QUIC/OpenSSL path.
Build
curl 8.21.0 from source with
--enable-ssls-export(for--ssl-sessions); OpenSSL 3.6.3; ngtcp2 1.25.0, nghttp3 1.18.0; macOS:The output of
curl -V:Reproducing the issue (OpenSSL)
I ran curl twice against the same HTTP/3 origin. The first run just obtains an early-data-capable session ticket; the second, resumed run adds
--tls-earlydata, where I expected the request to leave as early data (a 0-RTT packet in the first flight, with the response coming back ~0.5-RTT later). Exact commands:curl's output on that second run looks exactly like early data was used and accepted:
But when I looked at a tcpdump of that same run, I saw the opposite. The client's first datagram is just an Initial padded to fill the ~1200-byte datagram, with no 0-RTT packet coalesced, and the server's response only arrives about one round trip after its handshake flight - i.e. not the 0.5-RTT I expected:
Finally I decrypted the handshake with
SSLKEYLOGFILE, and I observed that the client never even offered early data. The ClientHello does carry the PSK extension (so it really is a resumption), but it has noearly_data(42) extension; there is no 0-RTT packet; and the server's EncryptedExtensions carry noearly_dataeither. The onlyearly_dataextension anywhere in the exchange is inside the server's NewSessionTicket - itsmax_early_data_size, meant for a future connection.Yet curl still prints "server did accept 96 bytes" and
%{tls_earlydata}returns 96.Expected behavior (GnuTLS, which works)
I rebuilt curl again only with GnuTLS instead of OpenSSL. The client's first datagram does carry a 0-RTT packet:
And the correctly server replied with 0-5 RTT.
Where the output seems to come from
I'm not an expert on curl code base, but I did my best to drill down the issue.
I read the source, and it looks to me like all of this comes from libcurl's backend rather than the command-line tool - the tool seems to only print what libcurl reports. The "SSL session allows N bytes..." line is in
cf_ngtcp2_on_session_reuse()(lib/vquic/cf-ngtcp2-cmn.c), which also restores the QUIC 0-RTT transport params and setsuse_earlydata. The "[HTTP/3] server did accept N bytes of early data" line and the value behind%{tls_earlydata}both come fromcb_ngtcp2_handshake_completed()in the same file.Digging a little into the OpenSSL path specifically: for HTTP/2 (TCP) curl does send early data, via
SSL_write_early_data()inossl_send_earlydata()(lib/vtls/openssl.c), reached fromossl_connect()- and my tests confirm early data works fine over HTTP/2 with curl + OpenSSL. For HTTP/3 the connection takes a different path (lib/vquic/cf-ngtcp2-cmn.c,Curl_cf_ngtcp2_cmn_connect(), driving the OpenSSLSSLviangtcp2_crypto_ossl) and never callsossl_send_earlydata()/SSL_write_early_data(). I could not find anywhere on that HTTP/3 OpenSSL path where client early data is actually enabled or sent - which matches the ClientHello going out without theearly_dataextension.A few more data points
osslclient), fromexamples/client.ccin the ngtcp2 project. It does send real early data: a 0-RTT packet in the first flight and a 0.5-RTT response. So ngtcp2 and OpenSSL can do 0-RTT here; the difference seems to be in how curl drives them.Docs
Separately, the
CURLSSLOPT_EARLYDATAdocumentation is still ambiguous about QUIC:In the same paragraph it says "It works on TCP and QUIC connections" and "This option does not work when using QUIC." It needs to be corrected to state one consistent behavior.
Happy to share more details if you need.
Thanks!
I expected the following
Early data sent, just like in the GnuTLS case.
curl/libcurl version
curl 8.21.0
operating system
MacOS 15.7