TLS 1.3 is the rare standard that made things both faster and safer at the same time. It cut the handshake from two round trips to one, threw out every broken cipher, and made forward secrecy mandatory. Almost every change was an unambiguous win.

Then there’s 0-RTT.

0-RTT — “zero round-trip time,” also called early data — lets a returning client send application data in its very first packet, before the handshake finishes. No waiting for the server to say hello back. On a resumed connection it shaves off a full round trip, which on a high-latency mobile link is the difference between a page that feels instant and one that feels sluggish. It is the one feature in TLS 1.3 that people turn on specifically for speed.

It is also the one feature in TLS 1.3 that gave back a security property the rest of the protocol fought to guarantee. And most people who enable it have no idea what they traded.

What you actually turned on

Here’s the mechanism, because the mechanism is the whole problem.

Normal TLS data gets its keys from a fresh Diffie-Hellman exchange that happens during the handshake. Both sides contribute random, ephemeral key material, so the session key exists nowhere until the handshake runs and is thrown away after. That’s forward secrecy: steal the server’s long-term private key tomorrow and you still can’t decrypt the traffic you recorded today, because the key you’d need never touched disk.

0-RTT can’t wait for that exchange — the entire point is to skip the round trip. So early data is encrypted under a key derived from the pre-shared key (PSK) from the previous session, handed to the client as a resumption ticket. The client encrypts its first request with a key it already had, and sends it immediately.

Two things fall out of that, and RFC 8446 says both of them out loud in Section 2.3.

First, early data is not forward secret. It’s protected by keys derived from the offered PSK, not from a fresh ephemeral exchange. The server decrypts the resumption ticket with a session-ticket encryption key that, on most deployments, is long-lived and shared across a fleet. If that key ever leaks, every piece of 0-RTT data anyone recorded becomes readable — and the 0-RTT data is the first request of each connection, which is exactly where the session cookies and auth tokens live. The protocol spends enormous effort giving the rest of your traffic forward secrecy, and then lets the most sensitive bytes skip the line.

Second, and worse: early data is replayable, by design. The spec is blunt about this. The server has no cryptographic way to tell a replayed 0-RTT request from the original. An attacker who captures your early-data packet can send it to the server again — possibly many times, possibly to a different node in the same cluster — and the server will decrypt it, because it’s validly encrypted under a key the server accepts. There’s no nonce in the protocol that burns after one use. Replay resistance, which every other part of TLS gives you for free, simply isn’t there for the first flight.

“Just restrict it to GET”

The standard answer to the replay problem is to only allow 0-RTT for requests that are safe to replay. Cloudflare, for example, launched 0-RTT in 2017 allowing it only for GET requests with no query string, and tagged them with a header so the origin knows the request arrived as early data. The reasoning: a GET is supposed to be safe — it reads, it doesn’t change anything — so replaying it is harmless.

That reasoning rests on a promise that an enormous amount of real-world code breaks.

GET /logout. GET /api/charge?amount=10. GET /admin/delete?id=42. None of these should exist, and all of them do, in production, right now, written by people who never read the part of the HTTP spec that says GET must not have side effects. The “safe method” property is a contract the standard asks you to honor, not a law the server enforces. The moment a single GET in your app mutates state, restricting 0-RTT to GET stops being a mitigation and becomes a loaded gun pointed at that endpoint — now replayable, for free, by anyone on the path.

And the query-string restriction that’s supposed to help? It only pushes the side effects into the URL path, or into endpoints that read state that other requests mutate. “Idempotent in theory, dangerous in practice” is the recurring theme of 0-RTT security analyses, and it’s why the honest advice is: if you can’t prove a request has zero side effects, it must never ride 0-RTT.

The three defenses, none of which fully work

RFC 8446 dedicates all of Section 8 to anti-replay, and it’s worth reading precisely because of how carefully it refuses to promise a fix. It offers three mechanisms:

Single-use tickets (§8.1): the server remembers each resumption ticket and accepts it exactly once. Clean in theory. The trouble is that the whole reason you want 0-RTT is usually a CDN with hundreds of edge nodes, and “this ticket was already used” is shared state across all of them. Synchronizing that at edge latency, globally, is the kind of problem that eats engineering quarters. So many large deployments don’t do strict single-use.

ClientHello recording (§8.2): the server records a unique value from each ClientHello and rejects duplicates within a window. Same distributed-state problem, same reason it’s partial.

Freshness checks (§8.3): the server checks the ticket’s claimed age against how long ago it was issued and rejects anything outside a tight window. This bounds how long an attacker has to replay, but it doesn’t stop replay — it just shrinks the window to seconds. An attacker who replays immediately still wins.

Read them together and the spec’s own position is clear. With state shared across every server, single-use tickets or ClientHello recording can guarantee early data is accepted at most once — but the RFC concedes that “not all deployments will maintain state at that level,” sets a weaker floor (each server instance accepts a given 0-RTT handshake at most once, which caps replays at the number of instances), and tells clients to send only early data they deem safe to replay. Even perfect state doesn’t stop the cross-zone trick Section 8 describes, where a server that rejects 0-RTT pushes the client into a full handshake and a retry of the same request. That’s the quiet admission. 0-RTT replay isn’t a bug you can patch; it’s a property you manage.

The part of the stack that got this right

The group that took this most seriously is the HTTP working group, which wrote an entire RFC — 8470 — on how to use early data in HTTP without getting burned. Its key move is the 425 Too Early status code: an origin that isn’t sure a request is replay-safe can reject it and tell the client to retry the exact same request over a normal, fully-handshaked connection. It’s an escape hatch that says, explicitly, “I’d rather be slow than replayed.”

That’s the right instinct, and it’s the frame I wish more people applied to the whole feature. 0-RTT is not a free speed-up you flip on in your load balancer config. It’s a decision to accept replayable, non-forward-secret early data in exchange for one round trip, on the bet that nothing sensitive and nothing stateful ever rides in a client’s first packet.

For a static image CDN, that bet is fine — take the speed. For an API that does anything, or an app where the first request carries the session, it’s a bet you will eventually lose, and the loss looks like a charge that fired twice or a token you can’t un-leak.

The honest default, for almost everyone, is to leave 0-RTT off. The feature isn’t broken. It does exactly what it says. The problem is that what it says is “I traded your replay protection for 50 milliseconds,” and nobody reads the receipt.