chaowen.← Back to archive

Security / cryptography deep dive

TLS 1.3 0-RTT: faster reconnects by spending replay safety

TLS 1.3 can send application data on the first flight of a resumed connection. That removes a round trip, but the server cannot prove that the early data is being seen for the first time. The feature is therefore a deliberate trade: latency for replay resistance.

Security / cryptography deep dive · PSK resumption, early traffic keys, HTTP safety, anti-replay state, OpenSSL internals, and production debugging

2026-08-31

The concrete problem

A client that already connected to api.example often reconnects shortly afterward: a mobile radio wakes up, a browser opens another connection, or a service retries after a network path change. A normal TLS 1.3 connection uses one round trip to negotiate keys before the client can send an HTTP request:

client server | ---- ClientHello + key share ------------> | | <--- ServerHello + encrypted handshake --- | | ---- Finished + HTTP request ------------> | | <--- Finished / HTTP response ------------- | one network round trip before the request

For a 90 ms mobile round trip, that extra wait is visible. TLS 1.3 0-RTT uses a previously issued resumption ticket as a pre-shared key (PSK). The client can put application data in its first flight:

client server | ---- ClientHello + PSK + key share --------> | | + 0-RTT encrypted HTTP request | | <--- ServerHello + encrypted handshake ------ | | <--- response ------------------------------- | request can leave before handshake completion
The invariant: 0-RTT data is confidential and integrity-protected against outsiders who do not know the PSK, but it is not guaranteed fresh. A valid attacker who captures it may be able to submit the same ciphertext again.

What “0-RTT” actually means

It does not mean zero cryptographic work or zero network traffic. It means zero additional round trips before the client sends early application data. The client still computes a PSK binder, derives traffic keys, and normally includes a fresh Diffie–Hellman key share for the handshake that follows.

Property0-RTT early dataPost-handshake 1-RTT data
Key sourceResumption PSK, through the early secret.Handshake traffic secret, derived after the server contribution and key schedule complete.
Replay protectionNot provided by TLS alone; the same valid early data may be replayed.Fresh connection keys and protocol state make this different from the original record.
Forward secrecyEarly bytes depend on the PSK; compromise of that PSK affects them.Normally benefits from the fresh ephemeral key exchange.
When availableOnly on a resumed connection with a ticket that permits early data.On both new and resumed connections after the handshake reaches 1-RTT.

The “replayable” label does not mean passive eavesdroppers can decrypt and edit requests. The PSK binder authenticates the ClientHello, and the early traffic key authenticates the records. The problem is subtler: the same authenticated request can be accepted twice by a server that has no shared memory of having processed it.

Ticket to request: the protocol data flow

On the first connection, the server sends a NewSessionTicket after the handshake. The ticket is an opaque identity; it is not necessarily the key itself. The server stores or encodes enough state to recover the PSK later.

initial connection ServerHello ... Finished <-- NewSessionTicket { ticket, ticket_lifetime, ticket_age_add, max_early_data_size } resumed connection ClientHello { supported_versions = TLS 1.3 key_share = fresh ephemeral share pre_shared_key = ticket identity + binder early_data = present } + records encrypted under client_early_traffic_secret ServerHello ... EncryptedExtensions { early_data } // accepted, or absent // rejected ... Finished + records encrypted under application_traffic_secret_0

Several fields do important work:

If the server does not accept early data, it can still accept the PSK and complete an ordinary 1-RTT resumption. A correct client must retain enough request state to send the request after the handshake, or surface a failure if the application cannot safely retry it.

The TLS 1.3 key-schedule boundary

TLS 1.3’s key schedule makes the security trade visible. In simplified notation from RFC 8446:

early_secret = HKDF-Extract(0, PSK)
derived      = Derive-Secret(early_secret, "derived", "")
handshake_secret = HKDF-Extract(derived, ECDHE_shared_secret)

client_early_traffic_secret
  = Derive-Secret(early_secret, "c e traffic", ClientHello)

client_application_traffic_secret_0
  = Derive-Secret(master_secret, "c ap traffic", transcript)

The early traffic secret is derived before the server’s fresh key share is mixed into the handshake secret. That is why 0-RTT can be sent immediately and why it has weaker freshness and forward-secrecy properties. The later application secret includes the handshake’s ephemeral exchange and transcript.

The actual key schedule uses HKDF-Expand-Label and transcript hashes; the notation above is a map, not a replacement implementation. Record protection still uses the negotiated AEAD cipher, with monotonically increasing per-key record sequence numbers. A replay creates a new connection and a new record-protection context, so record-level sequence numbers do not stop it.

Open-source implementation landmarks

OpenSSL exposes 0-RTT as an explicit API because an application has to make the replay decision. The following are the useful boundaries in OpenSSL 3.x; exact helper names can move between releases, but the control flow is stable.

API or moduleRoleWhat to inspect when debugging
SSL_CTX_set_max_early_dataSets the maximum early-data size advertised in newly issued tickets.Zero means the server does not advertise 0-RTT for that context.
SSL_CTX_set_recv_max_early_dataSets the server-side receive limit for early data.Do not confuse the receive limit with the ticket’s advertised limit.
SSL_write_early_dataClient writes bytes before the handshake reaches 1-RTT.Returns the number accepted and may stop at the configured maximum.
SSL_read_early_dataServer reads, finishes, or rejects incoming early data.Handle SSL_READ_EARLY_DATA_FINISH and SSL_READ_EARLY_DATA_ERROR; do not process bytes after an error as trusted input.
SSL_get_early_data_statusReports whether the connection accepted, rejected, or did not attempt early data.Log this status with the request decision, not just the negotiated TLS version.
ssl/statem/extensions_srvr.c and extensions_clnt.cParse and construct early_data, PSK, and ticket-related extensions.Useful for following why a ticket was accepted but early data was not.
ssl/record/ and ssl/tls13_enc.cConnect record protection to TLS 1.3 traffic secrets and epochs.Useful when packet capture shows a record but the application says no early bytes arrived.

OpenSSL’s library can verify the cryptographic envelope; it cannot know whether “charge this card” is safe to repeat. That policy belongs above the TLS stack, where the application understands idempotency keys, transaction state, and business effects.

HTTP turns a cryptographic property into an application contract

HTTP requests have different replay consequences. A cacheable GET /profile is usually safe to repeat. POST /orders may create a second order. The TLS protocol cannot infer that difference from the encrypted bytes.

RequestDefault 0-RTT postureSafer design
GET /catalogUsually acceptable if reading twice has no side effect.Keep authorization and cache semantics correct; do not mistake “GET” for a guarantee of safety.
POST /ordersReject early data or risk duplicate creation.Require 1-RTT, or use a durable idempotency key and atomically deduplicate.
POST /paymentsHigh-risk replay target.Reject 0-RTT and require server-side transaction nonces, idempotency keys, and an atomic ledger check.
PUT /resource/idMay be safe if the operation is genuinely idempotent.Define the resource version and overwrite semantics explicitly.

RFC 8470 defines the HTTP Early-Data: 1 request header and 425 Too Early response. A proxy can add the header when forwarding a request received in early data. An origin that cannot safely process it can return 425; a client may then retry after the handshake completes. This is a protocol escape hatch, not a substitute for designing idempotent operations.

Production rule: enable 0-RTT only for endpoints whose replay behavior is explicit. For state-changing endpoints, “we have not observed a replay” is not an anti-replay mechanism.

Anti-replay is a distributed-systems problem

Suppose an edge cluster has ten TLS terminators. An attacker captures a valid early request and sends it to several anycast edges. If each process keeps an independent replay cache, every process may accept the same ticket-and-request pair. Local memory is not a cluster-wide guarantee.

captured valid 0-RTT request ┌───────────────┐ │ replay sender │ └───────┬───────┘ ┌───┼────┐ ▼ ▼ ▼ edge-A edge-B edge-C cache cache cache │ │ │ accepted accepted accepted The crypto is valid at all three edges. Only shared freshness state or application idempotency can make the business effect happen once.

Common defenses have different failure modes:

RFC 8446 deliberately does not mandate one universal cache. Different deployments have different ticket lifetimes, routing, privacy constraints, and availability goals. A replay cache that fails open preserves availability while sacrificing the guarantee; one that fails closed preserves the guarantee while potentially rejecting legitimate early data. That is an operational choice, not a cryptographic constant.

Compact reproducible lab

Goal and prerequisites

Run a local OpenSSL 3.x server, make one ordinary TLS 1.3 connection so it issues a resumption ticket, then reuse that ticket to send a line of early data. The server prints the line before the client has completed the resumed handshake. You need OpenSSL 3.x with TLS 1.3 support and a shell. Docker is an alternative, but the commands below run directly on macOS when Homebrew OpenSSL 3 is installed.

mkdir -p /tmp/tls13-0rtt-lab
cd /tmp/tls13-0rtt-lab
openssl version                 # require OpenSSL 3.x
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout key.pem -out cert.pem -subj '/CN=localhost' -days 1

Start the server in terminal A. -early_data tells s_server to read early bytes; -max_early_data makes the ticket advertise a non-zero allowance.

openssl s_server -accept 8443 \
  -cert cert.pem -key key.pem -tls1_3 \
  -early_data -max_early_data 16384 -quiet

In terminal B, first obtain a session ticket. Let the client reach EOF so OpenSSL can receive the post-handshake ticket and save it.

printf 'first-handshake\n' | openssl s_client \
  -connect 127.0.0.1:8443 -tls1_3 \
  -CAfile cert.pem -sess_out sess.pem -quiet

Now send early data using that saved session.

printf 'EARLY-REQUEST\n' > early.txt
openssl s_client -connect 127.0.0.1:8443 \
  -tls1_3 -CAfile cert.pem -sess_in sess.pem \
  -early_data early.txt -quiet

Expected observation: terminal A prints EARLY-REQUEST. The server has accepted the bytes in the early-data phase. The client may also print connection diagnostics such as Can't use SSL_get_servername because this deliberately minimal lab does not configure SNI; that message is unrelated to 0-RTT.

Variation 1: stop and restart the server, then repeat the second client command with the old sess.pem. Depending on the server’s ticket-key rotation and OpenSSL version, the ticket may be rejected or the early data may fall back to 1-RTT. This shows that a ticket is stateful server capability, not a permanent password.

Variation 2: change -max_early_data 16384 to -max_early_data 0, restart, obtain a new session, and try again. The client cannot send permitted 0-RTT data because the new ticket advertises no early-data allowance; a correct application must retry the request after the handshake or decline it.

Environment result: this host’s default openssl is LibreSSL and does not expose the OpenSSL 3 early-data interface, but Homebrew OpenSSL 3.6.3 is installed. The lab was run successfully with /opt/homebrew/opt/openssl@3/bin/openssl; use that absolute path if openssl version reports LibreSSL. If OpenSSL 3 cannot be installed, run the same commands in a Linux container with an OpenSSL 3 image.

Failure modes and tradeoffs

FailureWhat it looks likeMitigation
Early data rejectedConnection succeeds, but the request is delayed until 1-RTT or is retried.Expose early-data status and make the client retry safely.
Unsafe request accepted twiceDuplicate order, charge, job, or password-reset token.Disable 0-RTT for the route or enforce durable idempotency atomically.
Replay cache split by edgeEach node’s metrics look healthy while a repeated request succeeds on multiple nodes.Use shared atomic state, stable ownership with a failure plan, or application-level deduplication.
Ticket-key rotationResumption or early data suddenly falls back, increasing latency.Monitor acceptance rate; coordinate ticket-key lifetime with client retry behavior.
Oversized early flightClient truncates, fragments, or rejects data beyond the ticket allowance.Keep the ticket limit bounded and treat the limit as a protocol input.
Proxy mishandles 425Client retries a non-idempotent request, or a proxy hides the response.Test the full path and use request keys; do not assume every intermediary understands RFC 8470.
PSK compromiseCaptured early ciphertext becomes decryptable if the corresponding PSK is later exposed.Keep PSK lifetime limited, protect ticket keys, and reserve sensitive data for 1-RTT.

Security boundaries worth remembering

0-RTT does not weaken the server certificate check or the PSK binder. It does change what a valid participant can do with captured traffic. The main risks are therefore freshness, business semantics, and secret lifetime:

  1. Replay: a network attacker does not need to forge a request; replaying a valid one may be enough.
  2. Cross-node acceptance: every TLS terminator that can validate the ticket is a possible replay target unless freshness state is shared.
  3. Privacy: early data is encrypted on the wire, but a compromised ticket key or PSK changes the exposure story.
  4. Protocol confusion: do not let an upstream proxy strip the indication that a request arrived in early data before the origin applies its policy.
  5. Resource abuse: bound early-data bytes and parsing work just as you would for ordinary unauthenticated connection setup; a valid ticket is not a reason to remove rate limits.

How to debug a production deployment

  1. Log the handshake decision. Record TLS version, resumption result, early-data attempted/accepted/rejected, ticket age bucket, and the route policy. Never log PSKs or ticket keys.
  2. Correlate at the application boundary. Include a durable idempotency key or request ID in logs, and compare one business operation with all TLS connections that carried it.
  3. Measure both latency and safety. Track 0-RTT acceptance rate, fallback rate, 425 responses, retry rate, and duplicate-key hits. A higher acceptance rate is not automatically better.
  4. Test topology, not just one process. Replay the same captured test request against every edge, across deploys, failover, autoscaling, and ticket-key rotation. A single-node integration test cannot reveal a split replay cache.
  5. Use key logs only in controlled environments. OpenSSL and compatible clients can emit TLS secrets for packet analysis, but those files can decrypt captures. Restrict permissions, delete them after the test, and never enable them in production.

A useful dashboard separates “client attempted early data” from “server accepted it” and from “application allowed the route.” If those are collapsed into one counter, an operator cannot tell whether latency changed because tickets stopped being issued, the edge rejected early data, the proxy returned 425, or the application deliberately required 1-RTT.

The durable mental model

TLS 1.3 0-RTT is a fast path that spends a property TLS normally gives you for free: freshness. The PSK proves that the sender knows a previously established secret; it does not prove that this exact request has never been processed. Keep read-only or explicitly idempotent work on the fast path, keep side effects behind 1-RTT or durable deduplication, and treat the replay window as a distributed state-management problem.

When a 0-RTT deployment behaves strangely, ask four questions: was early data attempted, was it accepted, did every edge share the same replay policy, and could the application safely execute the request twice? Those questions map directly onto the TLS state machine, the load-balancer topology, and the business invariant.

Primary sources