TLS 1.3 0-RTT: when fast becomes dangerous
TLS 1.3 introduced 0-RTT (zero round-trip time) resumption: a returning client can attach
application data to its ClientHello, saving one full RTT. CDNs love the
latency win and turn it on enthusiastically. The catch: 0-RTT data is, by design,
replayable. Any active attacker who can capture it can replay it to the
same server, possibly multiple times, possibly to a different node. RFC 8446 spells this
out in §8 — most operators don't read §8.
The attack model in one diagram
Client Attacker Server
| | |
| --- early data --X--- captured -----> | (legitimate, 1st delivery)
| | |
| | --- replayed ----> | (attacker, 2nd delivery)
| | --- replayed ----> | (attacker, 3rd delivery)
| | --- different node-> | (attacker, geo-distributed)
What the attacker captures: encrypted bytes. They cannot read the request body. They can resend it as-is. The server has no cryptographic way to distinguish the replay from the original.
Why GET-only isn't a defense
The standard mitigation in CDN docs reads "0-RTT is safe for idempotent requests, so
limit it to GET." This is wrong twice over:
- GET is not idempotent in practice.
GET /api/charge?amount=10exists.GET /logoutexists.GET /admin/delete?id=42exists. CSRF tokens don't protect against replay because the captured request already has the token baked in. - Side effects on read. Counters, rate limits, "first-touch" coupons, A/B test bucket assignment, audit logs, IDS triggers. A replayed GET to a metering endpoint inflates your AWS bill.
Cloudflare, AWS, Azure: what's on by default
| Provider | 0-RTT default | Notes |
|---|---|---|
| Cloudflare | Off (opt-in via dashboard) | Limited to GET/HEAD when on; documented replay risk |
| AWS CloudFront | Off; requires explicit security policy | TLS 1.3 enabled but 0-RTT requires custom config |
| Azure Front Door | Off | No public toggle as of 2026-04 |
| Fastly | Opt-in per service | Documents single-use guarantee with caveats |
| nginx (with BoringSSL) | Off; ssl_early_data on | You must add $ssl_early_data protection |
If you've never explicitly enabled it, you're likely safe by default. The risk
concentrates in two places: (a) teams who turned it on chasing latency wins without
reading the spec; (b) custom Go/Rust services using tls.Config{MaxEarlyData: ...}.
Detecting whether your edge accepts 0-RTT
The OpenSSL one-liner:
openssl s_client -connect example.com:443 -tls1_3 -sess_out sess.pem -ign_eof <<< "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
openssl s_client -connect example.com:443 -tls1_3 -sess_in sess.pem -early_data req.txt
Where req.txt contains a raw HTTP request. If s_client prints
Early data was accepted, your endpoint is replay-vulnerable.
The single-use registry: why "we deduplicate" isn't enough
RFC 8446 §8.2 describes a "single-use ticket" anti-replay mechanism. Sounds great. In practice it requires the server to maintain a synchronized state across every node that might terminate TLS for that session ticket. For a CDN with 200 PoPs, this is either:
- An eventually-consistent global cache (race window = your replay window)
- Sharded by ticket prefix (attacker picks the shard)
- Per-PoP only (replay across PoPs is trivial via routing manipulation)
Cloudflare published a 2017 paper on this topic. The honest summary: best-effort, with windows up to ~10 seconds in practice. For high-value endpoints, "we deduplicate" should be read as "we deduplicate most of the time."
The right defense: kill 0-RTT for state-changing endpoints
In nginx with BoringSSL:
server {
ssl_early_data on;
location /api/ {
# Reject 0-RTT for the API
if ($ssl_early_data) {
return 425; # Too Early
}
proxy_pass http://backend;
}
location / {
# Allow 0-RTT for static assets — replay is harmless on idempotent reads of files
proxy_pass http://backend;
}
}
HTTP status 425 Too Early tells the client to retry with full handshake.
Modern browsers handle this transparently. The application gets the latency win on
static assets and the safety on writes.
UnveilScan and 0-RTT
Our tls_extended checker probes for 0-RTT acceptance and flags MEDIUM if
the server accepts early data without an explicit 425 path discipline. If
you've never turned it on, you'll never see this finding. If you turned it on for
latency and never differentiated by route, it'll show up immediately.
Audit your TLS 1.3 posture
Free Basic scan covers TLS basics. Extended adds 0-RTT detection, OCSP stapling, SCT verification, weak cipher checks.
Run a scan