
The Hypertext Transfer Protocol (HTTP) has come a long way from its humble beginnings on Tim Berners-Lee’s NeXT cube at CERN. It went through a number of iterations, has been abused in just about any conceivable way, chained with proxies, tunnels and caches, intercepted by middleboxes, and is, for all intents and purposes, the universal Internet pipe and primary content delivery mechanism.
RFC 1945 describing HTTP/1.0 was fairly easy to read, but since then, things have gotten pretty complex. As of May 2025, the number of HTTP-related RFCs ranges from about a conservatively estimated dozen (focused on core protocol definitions and HTTP semantics) to a few hundred (based on title searches across the RFC index).
HTTP/1.1 (RFC 2616 and onwards) remains the lowest common denominator that clients and servers need to support, and of course, modern stacks will want to use HTTP/2 (RFC 9113) and HTTP/3 (RFC 9114), but just how do they determine each other’s capabilities and bootstrap their connection?
Let’s take a look…
HTTP -> HTTPS
First, let’s get from plain text HTTP to HTTPS. Even though modern browsers may default to HTTPS these days (Chrome and Safari have since 2021, Firefox since 2024), other tools or libraries might not. So, how do we get to HTTPS if we’re making our initial connection via HTTP?
3xx redirect
The most obvious approach here is for the server to return a 300-level HTTP status code:
$ curl -I http-123.test.netmeister.org
HTTP/1.1 301 Moved Permanently
Connection: keep-alive
Location: https://http-123.test.netmeister.org/
Easy. A client receiving this result will then automatically follow the redirect, establish a TLS connection and then repeat the request:
$ curl --http1.1 -L -I http-123.test.netmeister.org
HTTP/1.1 301 Moved Permanently
Connection: keep-alive
Location: https://http-123.test.netmeister.org/
HTTP/1.1 200 OK
Alt-Svc: h3=":443", h2=":443"
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Type: text/html
Here, I specified --http1.1
to explicitly use HTTP/1.1. In the case of plain HTTP, that isn’t necessary: even though HTTP/2 in the clear (h2c
) is technically allowed by the specification, it’s not supported by the overwhelming majority of implementations. On the other hand, curl(1)
supports (and offers) HTTP/2 by default, but we’ll discuss that upgrade path below. So let’s stick with HTTP/1.1 for the time being and see what the above request looks like on the wire, using Wireshark (Figure 1).

Seeing the packets in Figure 1 helps us understand the cost of the redirect. After the initial DNS lookup (packets 1 and 2), we make a TCP connection (packets 3-5) and issue our HEAD
request (packet 6). We receive the 301 redirect (packet 7) and now have to make a new TCP connection to the same host (packets 9-11), then begin our TLS handshake (packets 13-23) before we can then make the now encrypted HTTP request.
Since the packets are now encrypted, we can’t see the HTTP request. Unless…
Using SSLKEYLOGFILE
to debug TLS connections
Several applications honour the SSLKEYLOGFILE
environment variable, which allows you to log the TLS session key, and which, for example, Wireshark can read to then decrypt the TLS packets. To use it, simply export SSLKEYLOGFILE=/tmp/tlskeys
, invoke the HTTP client (for example, curl(1)
or /Applications/Google\ Chrome.app
), and then drill down in Wireshark->Preferences->Protocols->TLS and set the pathname for ‘(Pre)-Master-Secret log filename’ to /tmp/tlskeys
.
Note that there’s an IETF draft in the TLS Working Group proposing a standardization of that SSLKEYLOGFILE
format, although there’s some discussion around whether key logging is something that should be encouraged by such a standardization.
Also note that on macOS the system curl(1)
does not support key logging, since that uses Apple’s SecureTransport
TLS backend. You’d have to install curl(1)
from, for example, HomeBrew and use that to get support for SSLKEYLOGFILE
.
Once you’ve loaded the TLS secrets in Wireshark, the same packets from above then become as shown in Figure 2.

The only difference here is that we can now see the TLS encrypted extensions and certificates (packet 21) and then the application layer protocol (HTTP/1.1; packets 24 and 26).
Remembering to use HTTPS
Now, given the overhead of the redirect, we probably want to convince the client to remember to talk to us over HTTPS in the future. For that, we are sending back the HTTP Strict Transport Security (HSTS) header (shown in yellow above). Since the initial request triggering the redirect was made using plain HTTP, an active Man-in-the-Middle (MitM) attacker could remove such a header were it included in the plain HTTP response, and could of course alter the redirect altogether. For this reason, popular browsers also offer the option to hardcode a domain into the browsers to only ever talk to that domain using HTTPS.
This preloading is done via the HSTS preload list and can be applied to the entire domain, including all subdomains. For example, my own domain is hard coded in Chromium (and from there dynamically included in Firefox and Safari, both of which consume the Chromium list). This can, at times, cause surprising behaviour when you want to offer a subdomain of a preloaded domain over HTTP, and you suddenly find your browser refusing to visit it using HTTP.
You can inspect the HSTS list in your (Chrome-based) browser via chrome://net-internals/#hsts
(Figure 3). If you visit a raw domain name (without an explicit http
or https
in the location bar), you can see the HTTPS upgrade in the Developer Tools network console shown as a 307 Internal Redirect
(Figure 4).
Now that 307
is a bit of a lie, since the client did not actually make any request to the server, but I suppose that’s just how the static HSTS preload list is implemented.
Note: Firefox used to expose HSTS information under about:networking#security
. There doesn’t appear to be a way to inspect the dynamic or static HSTS list any longer.
Upgrading to HTTP/2
Ok, so the next time we make a request, our client will know to use HTTPS. But how does it know whether to use HTTP/1.1 or HTTP/2?
In some cases, you may see an Upgrade: h2
header (see RFC 7230), asking the client to use HTTP/2. This is a bit of an oddity, since the HTTP/2 specification mandates that HTTP/2 negotiation MUST happen via the TLS Application-Layer Protocol Negotiation Extension (ALPN, RFC 7301). Some web servers may still set this header, for example, to maintain backwards compatibility or to support the rarely used h2c
mode. (I’m looking at you, Apache mod_http2.)
Much better: Set the Alt-Svc
header (see RFC 7838), telling the client that your server supports both HTTP/2 and HTTP/3. The client may then cache this information and the next time it makes a connection to this server, it would then utilize the given protocol. (There does not appear to be a way to inspect the Alt-Svc
cache in the different browsers; flushing it requires flushing all data for the given site.)
Note: The
header is generally not honoured by clients if set via plain HTTP, since this would allow an active MitM attacker to redirect traffic. You will note that it was hence not set by the server when we talked HTTP.Alt-Svc
But this doesn’t help us for this connection which the client could have made using HTTP/2, if it had known that the server supports it. As noted above, HTTP/2 mandates protocol negotiation to happen via ALPN. As a TLS extension, this happens in the TLS ClientHello
, and thus allows the client to determine the application layer protocol to use at TLS handshake time. Let’s observe that in action:
$ curl -L -I http-123.test.netmeister.org
HTTP/1.1 301 Moved Permanently
Content-Type: text/html
Connection: keep-alive
Location: https://http-123.test.netmeister.org/
HTTP/2 200
content-type: text/html
content-length: 272
alt-svc: h3=":443", h2=":443"
strict-transport-security: max-age=31536000; includeSubDomains; preload
And on the wire (Figure 5).

As before, we see the plain text HTTP/1.1 redirect (packet 7) lead to a new TCP handshake (packets 9-11), the client offering h2
in the ALPN extension (packet 13), the server selecting h2
(in packet 21), and the client then speaking HTTP/2 immediately (packet 24).
Upgrading to HTTP/3
Ok, so we’re able to get from HTTP to HTTPS and from HTTP/1.1 to HTTP/2. How do we get from here to HTTP/3? So far, our client hasn’t offered h3
, although the server has advertised in the Alt-Svc
header.
Since HTTP/3 uses QUIC over UDP for transport instead of TCP, any client that wants to speak HTTP/3 must be built against a QUIC-enabled TLS library, which is a bit of a mess. curl(1)
, for example, regards HTTP/3 support as experimental (unless built against ngtcp2 + nghttp3), so we’ll switch to using an actual browser for the next part. Fortunately, both Chrome and Firefox honour the SSLKEYLOGFILE
environment variable, making dissecting packets nice and easy.
$ export SSLKEYLOGFILE=/tmp/tlskeys
$ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome http-123.test.netmeister.org
Once Chrome has loaded the site, we then reload the page to trigger the protocol upgrade. The resulting sequence of packets observed is shown in Figure 6.

Figure 6 shows Chrome completing the TCP handshake (packets 5-7), the TLS ClientHello (including the offer to use HTTP/2 in the ALPN extension; packet 9), the server selecting HTTP/2 (packet 17), and Chrome then speaking HTTP/2 (packets 20 – 25).
The HTTP Alt-Svc
header it received in packet 25 included the directive h3=":443"
, so when we then reloaded the page (note: not shift-reload, which would have caused Chrome to ‘forget’ the Alt-Svc
for this site), Chrome could switch over to QUIC (packets 31 onwards) and then make the request using HTTP/3 (packets 44-45).
Going straight to HTTP/3
Ok, we can get to HTTP/3, but that took several round trips and multiple handshakes and then required the client to remember a setting from the server before switching protocols. That’s far from ideal.
Fortunately, we have a much better way. Note that in the above packet capture we’re seeing the client perform not only the A
record lookup for our domain, but also query the RFC 9460 HTTPS
DNS record. And that record supports the alpn
‘SvcParamKey’. So let’s add such a record to our domain:
$ host http-123.test.netmeister.org http-123.test.netmeister.org has address 45.79.180.226 http-123.test.netmeister.org has IPv6 address 2600:3c03::f03c:95ff:fe49:a5b http-123.test.netmeister.org has HTTP service bindings 1 . alpn="h3,h2" \ ipv4hint=45.79.180.226 ipv6hint=2600:3c03::f03c:95ff:fe49:a5b $
Note: Recent versions of the host(1)
command shipping with Bind perform the HTTPS
lookup automatically. You can also use recent versions of the dig(1)
command.
Now if we flush our DNS cache, delete all data from the browser and start it fresh from the command-line, we see the result in Figure 7.

Wait, Figure 7 is showing us that Chrome is doing both HTTP/2 and HTTP/3 in parallel? The HTTPS DNS record lookup provides the ALPN hint in packet 4, but then we are seeing Chrome initiate a TCP connection (packet 5) as well as a QUIC connection (packet 6). The TLS handshake over TCP is completed (in packet 34) when the HTTP/3 request has already been handled (packets 23 and 30), so Chrome then abandons its efforts and doesn’t bother to speak HTTP/2 over the established TCP+TLS connection.
This is an example of QUIC-TCP Racing (see also) following a modified ‘happy eyeballs‘ approach. We also observe that HTTP/3 is (in this case, anyway) faster than HTTP/2, which, after all, is the whole point to begin with.
Other browsers
Ok, that’s Chrome — what about other browsers?
Firefox behaves somewhat differently. For starters, Firefox only performs HTTPS
DNS lookups if it is using DNS-over-HTTPS. When that is enabled, and the HTTPS
lookup provides a result with an alpn
SvcParamKey, then Firefox does not appear to race QUIC/TCP and instead directly attempts HTTP/3. However, it will fall back to HTTP/2 if the QUIC handshake cannot complete within a given tolerance time.
Finally, Safari will perform the HTTPS
lookup immediately and directly use HTTP/3 if that was advertised in the alpn
SvcParamKey and fall back to HTTP/2 only if needed.
Oh, and one quick note: If you are using a proxy it may be the case that you can’t talk HTTP/3 at all, unless the proxy also handles UDP. For example, even though SOCKS5 supports UDP, Tor does not, and you may spend an hour trying to tcpdump(8)
and debug Firefox and wonder why it just won’t talk H3. Ask me how I know…
Summary
Ok, so let’s summarize how we get from HTTP/1.1 to HTTP/2 to HTTP/3. It’s useful to keep in mind the high-level differences between the three protocols, so let’s reference this useful image from Wikipedia (Figure 8).

With that in mind, we have seen the promotion from one protocol to the other via the following means:
- From HTTP to HTTPS:
- HTTP server-side redirect via status code
301
. This incurs an additional TCP handshake - Clients remember the redirect if the server sets an RFC 6797 HSTS header
- Clients may have a static (hard-coded) HSTS list (see hstspreload.org)
- Set an RFC 9460
HTTPS
DNS record (IN HTTPS 1 .
)
- HTTP server-side redirect via status code
- From HTTP/1.1 to HTTP/2:
- Negotiation of the protocol for this connection in the TLS handshake via the RFC 7301 ALPN extension
- Servers may set the RFC 7838
Alt-Svc
header to influence future connections - Set an RFC 9460
HTTPS
DNS record with analpn
SvcParamKey (IN HTTPS 1 . alpn="h2"
) - Clients may cache either result for future connections
- From HTTP/1.1 or HTTP/2 to HTTP/3:
- You cannot use the ALPN extension within a TLS+TCP handshake to upgrade this connection to HTTP/3, since this requires a protocol switch to QUIC
- Negotiation of the protocol for this connection in the TLS handshake inside QUIC via the RFC 7301 ALPN extension
- Servers may set the RFC 7838
Alt-Svc
header to influence future connections - set an RFC 9460
HTTPS
DNS record with analpn
SvcParamKey (IN HTTPS 1 . alpn="h3"
) - Clients may cache either result for future connections
- Clients may ‘race’ TCP and QUIC or fallback to HTTP/2 if QUIC fails
Or, even more tersely: Use ALPN to affect the current connection, Alt-Svc
to influence future requests, and use HTTPS
DNS records to minimize guessing and allow the client to immediately jump to HTTP/3.
And yes, things have gotten a smidgen more complex since the olden days of yore when all you had to do was make a TCP connection to port 80 and merely send GET /
…
Jan Schaumann is a Distinguished Infrastructure Security Architect, and Adjunct Professor of Computer Science, with an interest in information security and the overall health of the Internet, as well as the safety and privacy of its users. You can follow Jan on Mastodon.
This post is adapted from the original at Jan’s Blog.
The views expressed by the authors of this blog are their own and do not necessarily reflect the views of APNIC. Please note a Code of Conduct applies to this blog.