Skip to content

curl + OpenSSL does not send TLS early data (0-RTT) over HTTP/3, yet reports that it did, and the docs are ambiguous #22649

Description

@rarylson

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

HTTP/3h3 or quic relatedTLS

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions