QUIC is a transport that swallowed its own security layer. RFC 9000 defines streams, loss recovery, and connection migration; RFC 9001 hands key management to TLS 1.3 but throws away the TLS record layer, carrying handshake bytes in CRYPTO frames instead. The result encrypts nearly the whole packet, including most of the header - and then leaves the first flight readable by anyone who can see it, because the keys that protect an Initial packet are derived from a connection ID that travels in the clear. Every byte below is from the published test vectors in RFC 9001 Appendix A, re-derived rather than copied. Hover a field to light its bytes, click a field marked + for its lookup table.
Two header shapes. The long header carries a version and both connection IDs, and is used only until the handshake completes. The short header drops everything the peer already knows and is what carries virtually all real traffic. A single UDP datagram may hold several QUIC packets back to back, which is how a client sends Initial and Handshake packets together.
The header exactly as RFC 9001 Appendix A.2 defines it, before header protection is applied. The Initial & Keys tab shows what changes on the wire.
No version, no source connection ID, no length. Eleven bytes of header for a packet that may carry 1400 bytes of payload.
Not a real packet type: a long header with version 0x00000000, listing what the server does support. The client picks one and starts over.
0x?a?a?a?a are reserved to be refused. Same GREASE logic as RFC 8701 in TLS: a client offers a version it knows nobody implements, and a server that fails to negotiate cleanly gets caught in testing rather than blocking QUIC v2 forever. QUIC v2 (RFC 9369, version 0x6b3343cf) exists almost entirely for this reason - it is wire-compatible in shape but renumbers the long header packet types and changes the key-derivation salt and labels, so anything that hardcoded v1 constants breaks loudly.| bits | type | carries | keys |
|---|---|---|---|
| 0x00 | Initial | CRYPTO (the ClientHello), ACK, PADDING | derived from the destination connection ID - readable by anyone |
| 0x01 | 0-RTT | application data, before the handshake finishes | early traffic keys from the PSK |
| 0x02 | Handshake | CRYPTO (the rest of the TLS handshake), ACK | TLS handshake traffic secrets |
| 0x03 | Retry | a token, no packet number, no payload | none - integrity-tagged with a fixed known key |
QUIC v2 renumbers these to Initial=1, 0-RTT=2, Handshake=3, Retry=0. Nothing about the packets changed; the numbers moved so that ossified middleboxes fail visibly.
Initial packets are encrypted. They are also readable by every observer on the path, because both facts are true at once: the key is derived from a value that travels in the clear, using a salt printed in the RFC. This is not a flaw - it is integrity protection against blind injection, not confidentiality - but it is routinely misread as "QUIC encrypts the handshake".
Same HKDF-Expand-Label as TLS 1.3, "tls13 " prefix included - see the key schedule on the TLS page. The server side is identical with the label "server in". Every value above was re-derived for this page and matches RFC 9001 Appendix A byte for byte.
Applied after payload encryption, over the bits an observer could otherwise use to correlate packets: the low bits of the first byte and the packet number.
Sent when a server wants to validate the client's address before committing state. It carries a token the client must echo, and a 16-byte tag computed with a key published in the RFC. Provenance note: every other byte on this page was re-derived from field values; these 16 tag bytes are RFC 9001 Appendix A.4's published value, reproduced as-is rather than recomputed.
A QUIC packet has no payload structure of its own - it decrypts to a stream of frames, each self-describing, read until the packet runs out. Almost every length and identifier in QUIC is a variable-length integer, so the encoding is worth reading first.
The top two bits of the first byte give the total length. All four RFC 9000 Appendix A.1 test vectors, concatenated.
| 2msb | length | usable bits | range |
|---|---|---|---|
| 00 | 1 byte | 6 | 0 - 63 |
| 01 | 2 bytes | 14 | 0 - 16383 |
| 10 | 4 bytes | 30 | 0 - 1073741823 |
| 11 | 8 bytes | 62 | 0 - 4611686018427387903 |
0x25 in one byte and 0x4025 in two, and both are valid - RFC 9000 §16 explicitly permits the longer form. Anything comparing encoded bytes instead of decoded values is wrong. Frame types are varints too - which is how the type space extends past 63 without a flag day - but they are the one documented exception to the rule above: RFC 9000 §12.4 requires a frame type to use the shortest possible encoding, so a parser may compare those bytes directly.Offset and length, then raw handshake bytes. The first 16 bytes of the same ClientHello decoded on the TLS page.
0x17 disguise.Type byte 0b00001OFF: the low three bits say whether Offset and Length are present and whether this is the final frame. Here all three are set.
0x00 client-initiated bidirectional, 0x01 server-initiated bidirectional, 0x02 client-initiated unidirectional, 0x03 server-initiated unidirectional. Stream 0 is therefore the first client bidirectional stream, which in HTTP/3 is the first request. No handshake is needed to open a stream - you just send data on an ID you are allowed to use, and the limit is raised with MAX_STREAMS.This frame acknowledges packets 8 through 10 and 3 through 5. Ranges are encoded descending from the largest, each Gap counting the unacked packets between - and both Gap and Length are stored one less than their true value, because a zero-length range is meaningless.
I = Initial, H = Handshake, 0 = 0-RTT, 1 = 1-RTT. A frame in the wrong packet type is a PROTOCOL_VIOLATION.
| type | frame | I H 0 1 | note |
|---|---|---|---|
| 0x00 | PADDING | I H 0 1 | A zero byte. Also what pads an Initial to 1200. |
| 0x01 | PING | I H 0 1 | Elicits an ACK. Keepalive and path probing. |
| 0x02-03 | ACK | I H · 1 | 0x03 carries ECN counts. Never in 0-RTT. |
| 0x06 | CRYPTO | I H · 1 | Not in 0-RTT: early data is not handshake data. |
| 0x07 | NEW_TOKEN | · · · 1 | A token for the client to use on a future connection. |
| 0x08-0f | STREAM | · · 0 1 | Application data only. |
| 0x18 | NEW_CONNECTION_ID | · · 0 1 | Supplies spare CIDs for migration. |
| 0x1a-1b | PATH_CHALLENGE / RESPONSE | · · 0 1 | RESPONSE is 1-RTT only. |
| 0x1c-1d | CONNECTION_CLOSE | I H 0 1 | Only the 0x1c form is allowed in Initial and Handshake - 0x1d reports an application error, and there is no application yet. |
| 0x1e | HANDSHAKE_DONE | · · · 1 | Server to client. The TLS Finished still exists, in CRYPTO frames - this is the separate signal that the server considers the handshake confirmed, which is the point a client may discard its handshake keys. |
QUIC uses TLS 1.3 for key agreement and nothing else. There is no TLS record layer, no ChangeCipherSpec, no session ID, no EndOfEarlyData, and no version negotiation inside TLS - a QUIC connection that is not TLS 1.3 is not a QUIC connection.
The bracketed number is the packet number in that space. Initial, Handshake, and Application Data each number from zero independently, each with their own keys and their own ACK state - a lost Handshake packet cannot be acknowledged by a 1-RTT ACK, because they are different spaces.
| TLS 1.3 over TCP | in QUIC | why |
|---|---|---|
| record layer | gone | QUIC packets already frame and protect. A second framing layer would be pure overhead. |
| ChangeCipherSpec | gone | The middlebox-compatibility dummy is meaningless on UDP - nothing was ossified to look for it. |
| legacy_session_id | must be empty | Same reason. RFC 9001 §8.4 requires zero length. |
| EndOfEarlyData | gone | Packet number spaces already mark where early data ends. |
| key update | one bit | The Key Phase bit in the short header replaces the KeyUpdate message. Next secret is HKDF-Expand-Label(secret, "quic ku", "", 32). |
| TLS 1.2 fallback | forbidden | No downgrade path exists, so no downgrade protection is needed. |
QUIC's own negotiation rides inside the TLS ClientHello as a single extension. Flow control limits, idle timeout, and connection ID confirmation all live here.
original_destination_connection_id, and a client that sees the wrong value knows the Retry was injected.A TCP connection is its four-tuple: change the client's IP and the connection is dead. QUIC identifies a connection by an opaque connection ID chosen by the receiver, so the addresses underneath can change without the connection noticing. That single decision is what makes handoff between wifi and cellular survivable, and it creates a linkability problem QUIC then has to spend the rest of §9 solving.
active_connection_id_limit caps how many the peer must track.Path validation is a two-frame challenge-response with 8 bytes of unpredictable data.
A server that has lost its state cannot send a CONNECTION_CLOSE, because it no longer has the keys to encrypt one.
HTTP/3 is HTTP/2's semantics with the multiplexing deleted, because QUIC already does it. HTTP/2 built streams on top of one TCP connection and inherited head-of-line blocking at the transport; HTTP/3 maps each request onto its own QUIC stream, so a lost packet stalls only the streams whose bytes were in it.
| stream | type | carries |
|---|---|---|
| request | client bidi | HEADERS, then DATA, then optional trailing HEADERS. One request and its response, then closed. |
| control | uni 0x00 | SETTINGS first, then GOAWAY, MAX_PUSH_ID. Exactly one per side, never closed - closing it is a connection error. |
| push | uni 0x01 | A server push response, keyed to a PUSH_PROMISE sent earlier on a request stream. |
| QPACK encoder | uni 0x02 | Dynamic table insertions, in order. |
| QPACK decoder | uni 0x03 | Acknowledgements telling the encoder what the peer has processed. |
A unidirectional stream announces its purpose with a varint stream type as its first bytes. Unknown types are not an error - the receiver abandons the stream, which is how new stream types ship without a version bump. Types of the form 0x1f * N + 0x21 are reserved to be sent and ignored, the same GREASE trick QUIC uses on versions.
RFC 9114 §7.2. A frame on the wrong stream is a connection error, not a stream error - the whole connection dies.
| type | frame | control | request | push | note |
|---|---|---|---|---|---|
| 0x00 | DATA | no | yes | yes | Must follow a HEADERS frame. DATA arriving first is H3_FRAME_UNEXPECTED. |
| 0x01 | HEADERS | no | yes | yes | Request or response fields, then optionally again for trailers. |
| 0x03 | CANCEL_PUSH | yes | no | no | Either side, to abandon a promised push before it is delivered. |
| 0x04 | SETTINGS | first, once | no | no | Any other frame first on the control stream is H3_MISSING_SETTINGS; a second SETTINGS is H3_FRAME_UNEXPECTED. |
| 0x05 | PUSH_PROMISE | no | server only | - | Carries the promised request fields. A client sending one is an error. |
| 0x07 | GOAWAY | yes | no | no | A server sends the last stream ID it will serve; a client sends the last push ID it will accept. |
| 0x0d | MAX_PUSH_ID | client only | no | no | Raises the push budget. There is no way to lower it. |
| 0x02 0x06 0x08 0x09 | reserved | never | never | never | HTTP/2's PRIORITY, PING, WINDOW_UPDATE, CONTINUATION. Receipt anywhere is H3_FRAME_UNEXPECTED. |
| 0x1f*N +0x21 | GREASE | ignore | ignore | ignore | Reserved to be sent and ignored. Not an error - that distinction is the entire point. |
0x1f*N+0x21 series is inert: receiving one must be silently ignored, so implementations stay tolerant of frames invented later. One is a tripwire, the other is a vaccine.H3_CLOSED_CRITICAL_STREAM. The two QPACK streams are critical in the same way. This is a real departure from HTTP/2, where settings and pings shared the single connection - HTTP/3 has to keep several streams alive for the connection to mean anything.Frame type, length, then a QPACK-encoded field section. Four header fields in fourteen bytes.
0x02, 0x06, 0x08 and 0x09 - PRIORITY, PING, WINDOW_UPDATE and CONTINUATION in HTTP/2 - are reserved in HTTP/3 and receiving one is a connection error of type H3_FRAME_UNEXPECTED. They are not merely unused: they are actively rejected, so a proxy that blindly forwards h2 frames into an h3 connection fails immediately instead of half-working. Their jobs are gone anyway - flow control and pings belong to QUIC now, and CONTINUATION is unnecessary because a QUIC stream has no frame size limit.QPACK_BLOCKED_STREAMS, and an encoder that wants zero blocking simply never references the dynamic table, paying in bytes instead of latency.HPACK compresses HTTP/2 headers against a dynamic table that both sides mutate in lockstep - which works only because TCP delivers in order. QUIC streams have no order between them, so a table update on one stream may arrive after a reference to it on another. QPACK is HPACK rebuilt around that fact.
RFC 9204 §4.5. The high bits of the first byte select the form; the remaining bits are a prefixed integer that may continue into further bytes.
| pattern | name | meaning |
|---|---|---|
| 1Txxxxxx | Indexed | Name and value both come from table entry xxxxxx. T=1 static, T=0 dynamic. One byte for a complete header field. |
| 0001xxxx | Indexed, post-base | References a dynamic entry added after the Base. Relative addressing that stays correct however the insertions interleave with this section. |
| 01NTxxxx | Literal, name reference | Name from the table, value inline. N=1 means never index this field - set on authorization and anything else a CRIME-style oracle could probe, and it must survive being forwarded by an intermediary. |
| 0000Nxxx | Literal, post-base name ref | Same, but the name is a post-base dynamic entry. |
| 001NHxxx | Literal, literal name | Both spelled out. H flags Huffman coding on the name; the value that follows carries its own H bit in its length prefix. |
Two prefixed integers in front of every field section, and the whole reason QPACK can decode out of order.
2 * MaxEntries, offset by one, so it stays small and the decoder reconstructs the true value from what it has already inserted.S then a 7-bit prefix integer. S=0 gives Base = ReqInsertCount + DeltaBase; S=1 gives Base = ReqInsertCount - DeltaBase - 1. Base is the origin that relative dynamic references count back from, which is what lets one encoding stay valid no matter what else arrived first.:method GET at index 17 and :scheme https at 23 are single bytes because the whole pair is in the table. A request built only from static entries compresses well and can never block, which is why encoders default to exactly that and reach for the dynamic table only under sustained load.Each endpoint opens one of each, as unidirectional streams typed 0x02 and 0x03. Both are critical: closing either is a connection error.
| stream | instruction | pattern | does |
|---|---|---|---|
| encoder 0x02 | Set Dynamic Table Capacity | 001xxxxx | Resizes the table, bounded by the peer's QPACK_MAX_TABLE_CAPACITY. |
| encoder | Insert with Name Reference | 1Txxxxxx | New entry, name taken from the static (T=1) or dynamic (T=0) table. |
| encoder | Insert with Literal Name | 01Hxxxxx | New entry, name spelled out. H flags Huffman coding. |
| encoder | Duplicate | 000xxxxx | Re-inserts an existing entry at the head so it survives eviction. |
| decoder 0x03 | Section Acknowledgment | 1xxxxxxx | A field section on that stream ID decoded successfully - the encoder now knows those entries were understood. |
| decoder | Stream Cancellation | 01xxxxxx | That stream died; drop its outstanding references so entries can be evicted. |
| decoder | Insert Count Increment | 00xxxxxx | Acknowledges insertions the decoder has processed, so the encoder learns what it may safely reference without blocking. |
JA4q is JA4 with the protocol character q. Everything else about the derivation is identical, because the input is the same TLS ClientHello - it just arrives inside CRYPTO frames in an encrypted Initial packet instead of a TCP stream. As the Initial & Keys tab shows, that encryption stops nobody.
Example value carried over from the JA4+ breakdown so the two pages agree. The hashes are FoxIO's; the structural claims below are derived from RFC 9001.
Typical browser values, not protocol requirements. Only the version row is mandated by RFC 9001; the rest is what current clients happen to send, which is exactly what makes it a fingerprint.
| segment | TCP hello | QUIC hello | fixed? | why |
|---|---|---|---|---|
| protocol | t | q | n/a | The only difference in the algorithm itself. |
| version | 13 | 13 | mandated | RFC 9001 §4.2 forbids offering anything below TLS 1.3, so this segment carries no information on QUIC. |
| cipher count | 15 | 03 | convention | RFC 9001 §5.3 bars only TLS_AES_128_CCM_8_SHA256, so four suites are legal. Browsers offer three; the count drops from 15 because the TLS 1.2 suites a TCP hello carries for fallback are useless here. |
| ALPN | h2 | h3 | convention | QUIC does not constrain ALPN. HTTP/3 registers h3, so browsers send it; other protocols run over QUIC with their own identifiers. |
| extensions | 16 | 10 | convention | The QUIC hello adds quic_transport_parameters (57) and sheds the TLS 1.2 legacy set - no session_ticket, no ec_point_formats, no renegotiation_info. |