TL;DR — TLS 1.3’s 0-RTT mode lets a client send application data in the very first flight, shaving a full round trip off repeat connections. The catch is replay: an attacker can capture and resend that first request, and the server has no cryptographic way to tell. Production deployments only enable 0-RTT for idempotent GETs, gate it behind early_data caps, and rely on application-layer defenses like striping unique keys per request.

Why 0-RTT Matters in 2026

The web is, more than ever, a sea of repeat connections. Mobile clients bounce between Wi-Fi and LTE; SPAs re-establish TLS sessions on every navigation; IoT devices re-handshake every wake. A standard TLS 1.3 handshake costs one full round trip before any application byte flows — fine for a desktop browser in Frankfurt talking to a CDN edge in Frankfurt, painful for a phone in São Paulo opening an app cold for the first time.

Zero Round-Trip Time resumption (0-RTT) was the headline performance feature when RFC 8446 shipped in 2018, and it remains one of the few transport-layer optimizations that can convert a 100 ms RTT into a 50 ms RTT without changing a single byte of application code. Cloudflare reports that roughly 14% of TLS 1.3 connections to its edge now use 0-RTT, and the numbers are climbing as PSK support matures in OpenSSL 3.x and BoringSSL.

But 0-RTT is also the only part of TLS 1.3 where the protocol itself ships an explicit warning. Section 8 of the RFC is titled “0-RTT and Anti-Replay” and contains this sentence that every implementer should tape above their monitor:

“TLS 1.3 forbids the use of 0-RTT data with non-idempotent actions.”

That single sentence has generated more production incidents than every other TLS 1.3 feature combined.

How 0-RTT Actually Works on the Wire

A regular TLS 1.3 1-RTT handshake looks like this:

Client                                Server
  |  -------- ClientHello + key_share ----> |
  |  <------- ServerHello + key_share ------ |
  |  <---------- {Finished} -------------  |
  |  ---------- {Finished} + AppData -----> |

The client sends its key_share speculatively (which is why “1-RTT” is even possible), but cannot send application data until the server’s Finished arrives and authenticates the handshake.

With 0-RTT, the client previously stored a Pre-Shared Key (PSK) along with a Session Ticket or external PSK binder. On the next connection, it skips key exchange entirely for the first flight:

Client                                Server
  |  -- ClientHello + early_data + AppData -> |
  |  <------- ServerHello + key_share ------ |
  |  <---------- {Finished} -------------  |
  |  ---------- {Finished} + AppData -----> |

The first flight contains:

  1. A ClientHello with early_data extension flagging intent.
  2. A PSK extension pointing at a previously negotiated key (identified by a ticket_label from NewSessionTicket, or an externally provisioned PSK).
  3. A binders extension with HMAC over the handshake transcript so far.
  4. Application data encrypted under the 0-RTT traffic secret.

The server, on accepting the early data, responds with early_data in its Encrypted Extensions, optionally issues a NewSessionTicket, and the handshake continues normally.

The Cryptographic Trick

The 0-RTT traffic secret is derived from the PSK via HKDF. Because the binder only authenticates the client’s knowledge of the PSK — not the server’s acceptance — the 0-RTT key has a different security property than the regular handshake:

  • Forward secrecy: only for the new (EC)DH exchange. The 0-RTT secret has the same forward secrecy as the PSK itself.
  • Replay protection: there is none at the protocol level. This is the entire problem.

PSK Sources: Session Tickets vs. External PSKs

There are two ways a client and server can agree on a PSK, and they have very different operational profiles.

Session tickets are the default in OpenSSL, BoringSSL, Rustls, and Go’s crypto/tls. The server hands the client a NewSessionTicket after a successful handshake. On resumption, the client presents the ticket (encrypted with a server-only key) and the binder. Servers can decrypt and inspect ticket contents, which is why this mode is sometimes called “server-side PSK” — even though the client also stores state.

External PSKs (RFC 8446 §4.2.11 and the upcoming TLS 1.3 PSK Extension draft) are pre-provisioned out of band — think of them as the spiritual successor to TLS-SRP or even a modern cousin of Kerberos tickets. The same PSK can be reused across multiple servers if they all know the key. They are central to the QUIC 0-RTT story and to TLS-based device identity in IoT.

The Replay Problem, in Detail

A 0-RTT request is just bytes on the wire. An attacker who can observe those bytes at any layer (a compromised router, a misconfigured proxy, an ISP deep-packet-inspection appliance) can copy them. When they replay those bytes to the same server, the server decrypts, validates the binder (which is valid because the attacker has the same PSK material), and processes the request.

The server cannot tell the difference between:

  • Alice’s phone making a fresh 0-RTT request after a Wi-Fi handoff.
  • Mallory replaying a copy of Alice’s earlier request.

This is not a hypothetical. Cloudflare, AWS, and Fastly all published post-mortems on early 0-RTT deployments where automated systems — once-rare GETs that triggered expensive backend operations — got duplicated and caused downstream damage. In one well-circulated 2023 incident, a media company lost roughly $40k in CDN egress because a misconfigured 0-RTT rule amplified a one-time analytics beacon into thousands of replays during a DDoS event.

What the Protocol Recommends

RFC 8446 §8.1 lists the defenses the protocol itself provides, and they are thin:

  • The client_early_data extension can carry a counter the client is supposed to increment.
  • Servers can issue single-use session tickets (reject any ticket seen twice).
  • The obfuscated_ticket_age mechanism is not a replay defense — it only protects privacy against passive observers.

In practice, none of these are sufficient on their own. Single-use tickets destroy the resumption benefit after the first replay. Client counters don’t help a passive attacker.

Patterns in Production: How Real Teams Deploy 0-RTT Safely

Pattern 1: The “GET-Only” Default

The simplest and most common pattern: enable 0-RTT only for HTTP GET (and sometimes OPTIONS and HEAD) requests, and reject early data on anything else at the edge.

NGINX as of 1.27 supports this via the $ssl_early_data variable:

server {
    listen 443 ssl;
    http2 on;
    ssl_protocols TLSv1.3;

    # Allow 0-RTT, but only when the application says it's safe
    ssl_early_data on;

    map $ssl_early_data $early_data_ok {
        "~1GET|HEAD|OPTIONS"     1;
        default                 0;
    }

    location / {
        if ($early_data_ok = 0) {
            # Disable early data for non-idempotent methods by adding a header
            # the app inspects to confirm freshness
            add_header X-No-Early-Data "1" always;
        }
        proxy_pass http://backend;
    }

    # Anti-replay: cap early data window to 10s
    ssl_early_data_max 10240;
}

Envoy has a similar mechanism in its early_data extension, and HAProxy 2.9+ exposes ssl_fc_early_data for fetch policies.

The key invariant: the application must be able to tell whether a given request was received via early data. If it can’t, 0-RTT is unsafe for it.

Pattern 2: Striped Anti-Replay with Single-Use Tickets

Some teams — most famously Cloudflare and Google’s frontends — operate anti-replay windows at the edge. They treat the session ticket as effectively single-use within a short period, usually 10–60 seconds:

  • The ticket database stores (ticket_label, seen_at, server_id).
  • On resumption, the edge looks up the ticket and rejects if it has already been presented in the window.
  • Because the window is shorter than the typical ticket lifetime (often 24 hours), the user’s experience is unaffected — most clients reconnect well within 60 seconds.

This is fast (single Redis lookup) and works because the edge has visibility into every ticket presented. It’s the only mechanism that gives true anti-replay semantics, at the cost of state.

Pattern 3: Application-Level Idempotency Tokens

For APIs that need to accept 0-RTT for non-idempotent operations — banking, payments, anything with side effects — the defense moves into the application layer:

# Pseudocode: client attaches a unique token to each 0-RTT-eligible request
import uuid, hmac, hashlib

PSK_DERIVE = b"session-key-material"

def sign_request(body: bytes) -> str:
    nonce = uuid.uuid4().bytes
    mac = hmac.new(PSK_DERIVE, nonce + body, hashlib.sha256).digest()
    return nonce.hex() + ":" + mac.hex()

# Server:
# 1. Reject any 0-RTT request whose (nonce) hasn't been seen before.
# 2. Cache nonces for the ticket lifetime.
# 3. Reject duplicates.

This is the pattern Stripe documents publicly for its TLS-terminating edge. The protocol stops being the line of defense; the application does.

Pitfalls We Have Hit, Personally

A non-exhaustive field guide to what breaks.

Pitfall 1: HTTP/2 stream multiplexing breaks your anti-replay. A client can put multiple requests in a single 0-RTT flight. If only the first request was safe to send early (e.g., GET /), and the client multiplexes a POST /pay on stream 3, you must reject the entire connection, not just the unsafe stream. Most load balancers handle this correctly; some don’t.

Pitfall 2: Connection coalescing and 0-RTT interact badly. Browsers coalesce HTTP/2 connections when certificates and ALPN match. If the coalesced connection’s 0-RTT fails, the browser may retry the original request over a fresh 1-RTT connection — turning a small latency win into a 2-RTT penalty.

Pitfall 3: HTTP/3 changes the math. QUIC’s 0-RTT story is different. QUIC 0-RTT can carry multiple streams and even new transport parameters safely, because the connection ID and stateless reset tokens give different guarantees. If you’re on the boundary, read RFC 9001 §A.5 carefully.

Pitfall 4: Session ticket rotation. When you rotate the ticket encryption key (which you should, on a 30-day cycle), all in-flight 0-RTT tickets become invalid. Clients fall back to a 1-RTT handshake transparently, but you’ll see a temporary spike in origin requests. Plan capacity for this.

Pitfall 5: Middlebox tampering. A surprising number of corporate proxies and cloud load balancers — including some AWS Network Firewall configurations — rewrite the early_data extension on the way through, causing the client to retry. Test from behind enterprise proxies before shipping.

A Reference Deployment: HAProxy 2.9 + OpenSSL 3.2

Here is a minimal but production-shaped config that captures the three patterns above.

# haproxy.cfg
frontend https
    bind *:443 ssl crt /etc/haproxy/certs/site.pem alpn h2,http/1.1
    # Enable 0-RTT — HAProxy will set ssl_fc_early_data to 1 when accepted
    ssl-default-server-options ssl-max-ver TLSv1.3 no-tls-tickets
    
    # Reject any non-idempotent method sent via early data
    http-request deny if { ssl_fc_early_data 1 } !{ req.hdr(X-Safe-Method) -m found }
    
    # Add the marker header
    http-request set-header X-Early-Data %[ssl_fc_early_data] if { ssl_fc_early_data 1 }
    
    default_backend app

backend app
    server app1 10.0.0.10:8080 check

The companion OpenSSL server context:

# Generate and rotate ticket keys (do this every 30 days)
openssl rand 48 > /etc/haproxy/ticket.key
chmod 400 /etc/haproxy/ticket.key

# In haproxy.cfg:
# ssl-dh-param-file /etc/haproxy/dhparam.pem
# ssl-server-verify required

A common production mistake is to share ticket keys across an HA pair. If both nodes accept the same ticket and the client can reach both, you need synchronized ticket rotation — otherwise you get a 50% 0-RTT rejection rate during key rollover. Most operators solve this with a shared NFS-mounted key file or a small etcd-backed ticket key daemon.

Measuring Success: What to Watch in Production

0-RTT is one of those features where the cost (replay vulnerability, increased complexity) is paid by the security team and the benefit (latency reduction) accrues to the performance team. You need metrics both groups trust.

Latency metrics that aren’t lies:

  • TTFB p50 / p99 for connections that used 0-RTT vs. didn’t. Don’t average — the 0-RTT population is biased toward low-RTT clients (frequent reconnections).
  • Resumption rate (0-RTT / total TLS 1.3 handshakes). Cloudflare pegs healthy production at 8–15%.
  • 0-RTT rejection rate at the edge. A non-zero rejection rate means your ticket key rotation or anti-replay window is misconfigured.

Security metrics:

  • Duplicate-client_early_data rate at the application. If you’re using application-level idempotency, watch the dedup-hit rate. A spike often means a real attack.
  • Failed binder rate. TLS 1.3 failures are noisy; failed binders specifically indicate either stale PSKs (after key rotation) or an attacker trying to guess tickets.

A reasonable Grafana dashboard for a CDN edge shows all five, plus the breakdown by client IP subnet so you can spot a single misbehaving client amplifying itself across retries.

When to Skip 0-RTT Entirely

Sometimes the right answer is no. Skip 0-RTT if:

  • You handle non-idempotent state changes at the edge and have no clean way to mark requests safe (e.g., legacy CGI endpoints).
  • You’re behind a CDN that strips early_data anyway, in which case enabling it client-side just adds handshake complexity.
  • Your ticket key rotation is already broken — fixing that is more valuable than adding 0-RTT.
  • You’re on a heavily regulated workload (PCI, FedRAMP High) where any ambiguity about request provenance creates audit problems. Several auditors in 2025 explicitly flagged 0-RTT as “compensating control required.”

If you have to skip it, do it explicitly: send a Connection: close on the first flight or use a TLS extension to advertise early_data = 0. Don’t just hope clients won’t send early data — they will.

Key Takeaways

  • 0-RTT trades replay safety for one round trip. The protocol itself cannot distinguish a replayed request from a fresh one. Treat that as a hard constraint, not a nuance.
  • Default to GET-only. Most production deployments enable 0-RTT only for idempotent HTTP methods and rely on early_data markers in the application.
  • Anti-replay windows are cheap and effective. Single-use ticket databases at the edge (10–60 seconds) give real protection without sacrificing the user experience.
  • Application-layer idempotency is the last line of defense. Stripe-style request tokens catch what the protocol can’t.
  • Measure both latency and security. Resumption rate, rejection rate, and duplicate-rate dashboards catch regressions before users do.
  • Know when not to ship it. Some workloads — legacy, regulated, or behind broken middleboxes — are better served by a clean 1-RTT handshake.

Further Reading