Articles / Security

  • tls
  • tls-1-3
  • 0-rtt
  • replay-attacks
  • nginx
  • go

0-RTT Replays: Why TLS 1.3 Early Data Can Run Twice

Turn on 0-RTT in TLS 1.3 and a request can be executed twice, even though nobody broke the encryption. The attacker only needs to record the first flight of packets and send it again. Your server sees a perfectly valid ClientHello with valid early data.

Strictly, the handshake is not what gets replayed. The early data is. The handshake will fail for the attacker, but by then your application may already have acted on the request.

What 0-RTT actually does

On a first visit, client and server do a full handshake and the server hands over a session ticket. On a later visit the client can resume with that ticket and send application data in its very first flight, encrypted under a key derived from the earlier session. That saves a round trip.

The catch is in what that key is derived from. It comes from the resumption secret alone, with nothing fresh from the server. So the server has no way to say "this is new, I just contributed randomness to it".

Why a replay works

An attacker on the path copies the client's first flight and sends it to the server again. The steps look like this:

  1. The client sends ClientHello with a PSK identity (the ticket) and early data.
  2. The attacker records those bytes and forwards them as normal.
  3. Later, the attacker sends the same bytes to the same server, or to another server that accepts the same ticket.
  4. The server derives the same early-data key from the ticket, decrypts the data and hands it to the application.

The attacker cannot finish the handshake, because it lacks the client's ephemeral private key. It cannot read the response either. It does not need to. If the early data was POST /transfer, the side effect has already happened.

Quick detour: the duplicate you did not need an attacker for

Hang on, it gets slightly worse. A client that sends early data and then times out may legitimately retry it on a fresh connection. The server cannot tell that apart from a replay, and the protocol says so plainly. RFC 8446 section 8 treats duplicates as something the application has to tolerate, not a corner case.

What the server can do about it

The RFC lists three defences, and all of them are partial:

  • Single-use tickets: remember each ticket and reject a second use. Needs shared state if you have more than one server.
  • Record every ClientHello for a time window and reject duplicates.
  • Check freshness using the ticket age the client reports, so only recent flights are accepted.

Freshness narrows the window but cannot close it. A replay inside the window passes. And the first two only work where the state is actually shared: if ten servers behind a load balancer all accept the same ticket and none of them talk to each other, an attacker gets one replay per server.

The RFC is explicit that this is a trade-off, and that the application layer should decide what is safe to put in early data. My opinion: if you cannot say exactly which requests are idempotent, leave 0-RTT off.

The practical fix: tell the backend, let it say 425

If a TLS terminator accepts early data, the backend needs to know. RFC 8470 defines an Early-Data: 1 request header for that, and a 425 Too Early status the backend can return. A compliant client then retries once the handshake is complete, when replay is no longer possible.

With nginx, early data is off by default. To enable it and pass the signal on:

ssl_protocols TLSv1.3;
ssl_early_data on;

location / {
    proxy_set_header Early-Data $ssl_early_data;
    proxy_pass http://backend;
}

On the backend, a small Go middleware refuses anything that is not a safe method when it arrived as early data:

func rejectEarlyUnsafe(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Early-Data") == "1" {
			switch r.Method {
			case http.MethodGet, http.MethodHead, http.MethodOptions:
			default:
				http.Error(w, "too early", http.StatusTooEarly)
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

Check it did something

Use openssl s_client -connect host:443 -tls1_3 -sess_out sess.pem to save a session, then reconnect with -sess_in sess.pem -early_data req.txt where req.txt holds a raw HTTP request. Look at the backend log to see whether Early-Data: 1 arrived and whether you returned 425.

Two traps in the middleware

First, "GET is safe" is a convention, not a law. If your GET /unsubscribe?id=... changes state, early data can replay it. Audit the routes, do not trust the verb.

Second, the header only helps if every hop sets it. A proxy that terminates TLS and forgets proxy_set_header silently turns your backend's protection into a no-op, and nothing will complain.

Also worth knowing: early data is not forward secret, because it depends on the long-lived ticket key. That is why rotating session ticket keys matters even more once 0-RTT is on. For many sites the saved round trip is not worth the audit, and that is a perfectly sensible place to land.