← pybrws

Firefox has two TLS fingerprints, and they disagree

Part three: what I found when I pointed a packet capture at my own laptop and read everyone's QUIC handshakes.

Ernesto Pérez Costa · October 2026 · ~10 min

This is the third of three. The first was about copying a Firefox ClientHello byte for byte; the second about Chrome, which draws a new extension order every connection.

I had a working client at this point and I would happily have stopped. The reason I did not is that a growing share of real browsing is HTTP/3, and a client that speaks HTTP/2 while claiming to be a browser that prefers HTTP/3 is announcing itself just as loudly as one with the wrong cipher list. If I was going to claim the profile was Firefox, it had to be Firefox on the transport Firefox actually picks.

TL;DR

QUIC carries the TLS handshake inside packets encrypted with a key derived from a salt printed in the RFC, so anyone can read anyone's ClientHello. I wrote a decoder and pointed it at my own traffic.

Firefox keeps a fixed extension order over TCP and shuffles it over QUIC. Same browser, same version. The two transports are different codebases and they have different habits. I also got three things wrong reading the capture, which is most of what this post is about.

First problem: I could not see anything

Everything in part one came from running a TLS server myself and letting the browser connect to it. That does not work for QUIC. It is UDP, the handshake is encrypted from the first byte, and standing up a QUIC server with a certificate a browser will accept is a much bigger errand than standing up a TLS one.

Then I read RFC 9001 and found this, which I still think is the most surprising thing in the whole protocol: the keys protecting an Initial packet are derived from a salt published in the RFC itself. It is a fixed constant. Anyone can compute them.

That is deliberate, not a flaw. Those keys exist so that off-path attackers cannot inject packets and so middleboxes cannot parse the handshake fields and ossify them, not to keep the handshake secret — a ClientHello is public in TLS over TCP too. But it has a consequence that took a second to land: I do not need a server at all. I can read the handshakes already flying past.

Reading an Initial packet

It is about an afternoon of work, in this order:

  1. Take the connection's destination id out of the clear part of the header, mix it with the published salt, derive the initial secrets.
  2. Undo header protection. The packet number is masked with a mask derived from a sample of the ciphertext — and you need the packet number to know where the ciphertext starts. It unwinds in a fixed order, but it is the one genuinely fiddly part.
  3. Decrypt the payload, walk the frames, find the CRYPTO frames and reassemble them by offset, because the hello is usually split across several packets.
  4. Out comes an ordinary ClientHello, the same structure as part one.

I deliberately wrote this against nothing but a crypto primitives library. There is a QUIC library in the project for the sending side, and there is a test that asserts the decoder still imports with that library hidden. A measuring instrument that depends on an implementation inherits that implementation's opinions, and I wanted to be able to catch the library being wrong.

What I actually captured

Here is the setup, because it matters for everything below. I ran a capture filtered to UDP port 443 on my own Windows laptop and browsed normally for a few minutes with Firefox. Not a lab, not a clean room — my actual machine, with whatever else it had open.

231 UDP datagrams
 12 connections with long headers
  7 ClientHellos recovered

Seven hellos. I printed their extension lists and the orders were all different. Seven connections, seven orders, no repeats. I wrote down that Firefox shuffles over QUIC and very nearly wrote down something stronger — that the extension set varies too, because the sets did not all match either.

The capture was two browsers

The set not matching is what saved me. One of the seven, to www.bing.com, had eleven extensions where the others had fifteen. I went to look at why, expecting a truncated reassembly, and found something else: it was missing every marker I had been using since part one to tell Firefox's TLS stack from Chrome's — renegotiation_info, delegated_credentials, status_request, extended_master_secret, record_size_limit. Its QUIC transport parameters were google_connection_options, initial_rtt, google_version.

That is not Firefox. It is something Chromium-shaped, and on that machine it was the Chrome I had left open in another window.

So "7 orders across 7 connections" was 6 from one browser and 1 from another, and I had been one step from publishing it.

Obvious in hindsight. A capture of UDP 443 contains whatever the machine was doing, and a machine is always doing several things. It was not obvious while staring at the output, because the output was a list of orders and the thing that distinguished the clients was in a column I had not printed.

The tool now groups connections by extension set before it says anything about order, so a second client shows up as a second client rather than as noise inside the first one's measurement.

Six orders from one browser

With the stray connection removed, the six remaining ones all carry an identical set of fifteen extensions:

0  5  10  13  16  23  27  28  34  43  45  51  57  65037  65281

…in a different order every time. Six connections, six orders, no repeats. Two never move: quic_transport_parameters then encrypted_client_hello, pinned at the end, in that order, in all six.

And one of the six goes to incoming.telemetry.mozilla.org, which nothing but Firefox contacts, while the other five share its exact transport parameter sequence. So this is one client, it is Firefox, and it shuffles.

Which is the opposite of what it does over TCP, where the order is fixed enough that I built an entire byte-identity test suite on it. The explanation is unglamorous: Firefox's TLS-over-TCP comes from NSS, written in C, and its QUIC comes from neqo, written in Rust. They are different programs by different people. There is no reason they should agree, and they do not.

What this does to a test

The fixture I had captured an hour earlier was one sample of a distribution, not a reference, and no test may assert its order. What a test can assert is the relationship between samples: same set every time, different order every time, exactly two pinned at the end. That is what it holds now.

Three other things QUIC changes

Three cipher suites instead of fifteen. 0x1301 0x1303 0x1302, the TLS 1.3 ones only, because QUIC forbids TLS 1.2 outright. This is the cheapest QUIC tell there is. A client offering the TLS 1.2 suites over QUIC has not thought about it for one second, and it costs a server nothing to check.

Three extensions disappear. ec_point_formats, session_ticket and signed_certificate_timestamp. The first two follow from TLS 1.2 being unavailable. The third does not obviously follow from anything and I have it written down as an observation rather than an explanation.

The transport parameters are not in ascending order. reset_stream_at (0x1d) and a GREASE id come before max_datagram_frame_size (0x20). The QUIC library I was using as a reference sorts them; Firefox does not. Which means quic_transport_parameters is a whole second fingerprint surface that only exists on this transport: which parameters you send, in what order, with what values.

Then I made the same mistake again, one hour later

I wrote that Firefox draws its GREASE transport parameter per connection, "with an eight-byte one observed". It does not. 0xff02de1a appears in all six Firefox connections. The eight-byte one belonged to the Chrome connection.

I had mixed the two clients together again, in exactly the way I had just finished modifying the tool to prevent.

The constant turned out to be more interesting than the variation would have been. A value that never changes is not greasing anything, and this one carries the varint 1000, which is a plausible min_ack_delay. The registered codepoint for that is 0xff04de1b, one of a family of 0xff0?de1? draft codepoints — so this is most likely an old draft of it that happens to satisfy QUIC's reserved pattern by coincidence. The tool still labels it GREASE, because the formula says so, and labels it unidentified, because it is.

I should add that I wrote the transport parameter table from memory first, and it was wrong where it mattered: 0x1d is reset_stream_at, not unknown, and I had min_ack_delay at the wrong codepoint entirely. One fetch of the IANA registry settled it. The only reason I checked at all is that the arithmetic on the GREASE pattern was cheap enough to do, and it disagreed with me.

The theory that was too good

One host in the capture, cloudflare-ech.com, has Firefox's exact extension set and Firefox's pinned tail, but sends three transport parameters where the other five send fourteen. No flow control credit at all. That is not a working set; a connection configured like that should not function.

I had a lovely explanation ready within about a minute. The host publishes a real ECH configuration. What I captured is therefore the outer decoy hello, and the real parameters are encrypted in the inner one. It fitted the hostname, it fitted the missing parameters, and it would have been the first real ECH I had seen in the whole project.

Its ECH extension is 282 bytes. The same greased length as every other connection in the capture. There is no real ECH anywhere in there.

That guess was plausible, fitted the evidence I had, and was simply wrong. It is the argument for printing the column rather than reasoning about what it would contain — the number that killed it took no thought at all, it just had to be on screen.

What held up instead was arithmetic. Three parameters measure about 26 bytes on the wire against 86 for fourteen, a difference of 60. Two of the hosts have server names of equal length and hellos of 1,900 and 1,840 bytes. Sixty, predicted from the parameter count alone and confirmed by a measurement that does not depend on the decoder that produced the count. So the three parameters are real and the decoding is fine.

What Firefox is doing differently with that one host, I do not know. It is one connection in six and nothing rests on it. If anyone recognises this behaviour, I would like to hear about it.

Asking a stranger

Everything above was read off my own capture with my own parser, and checked against my own QUIC server. Which is the weakness of this whole post: I wrote the thing, I wrote the ruler, and I wrote the examiner.

thumbprint.me serves HTTP/3 and reflects the caller's fingerprints back as JSON, which makes it the first outside party that could be asked the QUIC question at all. Over TCP it reports t13d1517h2_…. Over QUIC, from the same client, the same machine, the same minute:

q13d0315h3_55b375c5d22e_dc5437974b47
                   ciphers: 1301,1302,1303

Read the prefix: q for QUIC where TCP said t, TLS 1.3, SNI present, three cipher suites where the TCP arm sends fifteen, fifteen extensions, ALPN h3. The three are exactly the TLS 1.3 suites. Until this run, "QUIC forbids TLS 1.2, so the list collapses to three" was a claim resting on my own parser reading my own bytes. Now a stranger says it.

It also reports how many of its own recorded Firefox sessions share each of our fingerprints: 103 for the QUIC ClientHello, 18 for the transport parameters. Which is the number I would have least expected to be non-zero, because quic_transport_parameters is the surface I spent the most of this post being wrong about.

One honest residual, since it is the kind of thing this post has been about. We send Alt-Used over HTTP/3 because that is what the capture contained — but Firefox sends it precisely because it reached h3 the long way, via Alt-Svc on an earlier TCP connection. This client dials QUIC directly, so it announces having used an alternative service that was never offered to it. The server knows whether it ever advertised h3 to you. Every individual header correct, the combination describing something that did not happen.

A server that cares which transport you used

Fingerprint services tell you what you look like. They do not tell you whether it matters. For that you need somebody who did not agree to be measured, so: indeed.com, four interleaved rounds, three arms on the same address within the same minutes.

pybrws over TCP ...... 0/4 served
pybrws over QUIC ..... 4/4 served
httpx over TCP ....... 0/4 served

Same library, same profile family, same machine. The transport flipped the outcome.

My first instinct was that the QUIC arm had simply been redirected somewhere unprotected, which would make the whole thing a bookkeeping error. So, one direct fetch of mx.indeed.com, no redirect following, bodies kept:

over TCP    403   "Security Check"      27,708 bytes
over QUIC   200   the actual page      591,241 bytes

Same URL. One of them is a refusal, the other is half a megabyte of job listings. That killed the alternative explanation and the finding survived.

What it does not establish is that QUIC is the reason. The QUIC profile differs from the TCP one in far more than transport: three cipher suites instead of fifteen, a drawn extension order instead of a fixed one, three extensions missing, its own header set. "The transport" here means the entire presentation that comes with it, and I cannot decompose that from outside.

Nor do I know whose doing the TCP refusal is. The test that would settle it needs no code at all: a real Firefox with network.http.http3.enable set to false, pointed at indeed. If a genuine browser over TCP also gets the Security Check, then indeed challenges TCP broadly and my fingerprint was never the reason. I have not run it. It is the most valuable thing left on the list and it is one checkbox.

What I would take from this

If you are building an imitation: a browser is not one fingerprint, it is one per transport, and the transports do not share code so they do not share habits. Test your HTTP/3 path separately or you will ship a client whose two halves describe two different programs. Mine did, for about a day.

If you are on the detection side: QUIC hands you a second independent signal, readable without any key, and quic_transport_parameters is a surface most imitation tools have not reached yet. The TLS 1.2 cipher suite check alone catches anything careless.

And for me, the real lesson of this post is smaller and duller than the finding. Every mistake above was caught by data that was already in the capture and was not being displayed. Not by cleverness, not by a better theory. By printing one more column.

From the repository