Skip to content

The TLS terminating proxy: where your handshake really ends

· 22 min read
Copyright: MIT
Diagram of a client TLS handshake ending at an edge proxy, with a second handshake to origin

You type a domain, the padlock appears, and you assume there is one encrypted pipe running from your laptop to the server that owns the data. There usually is not. For most of the traffic on the modern web, the TLS handshake your browser negotiates ends a few milliseconds away at a CDN point of presence, sometimes in the same metro area as you, sometimes on the same physical box that just served ten thousand other domains. The certificate you validated belongs to that edge. The server that holds your shopping cart never saw your ClientHello.

That gap is not a bug. It is the design that makes the fast web possible, and it has consequences that ripple all the way out to bot detection, abuse logging, compliance audits, and the question of who, exactly, is reading your plaintext. This post is about where the handshake really ends, and what changes once you accept that the answer is “not at the origin.”

We will start with why termination at the edge exists at all, then walk the two-handshake path a request takes through a terminating proxy. From there we look at the fingerprint problem: once the edge re-originates TLS, the origin sees the edge’s handshake, not yours, and everything an origin wants to know about the client has to be smuggled forward in HTTP headers. We cover the encryption modes that decide whether origin traffic is protected, the keyless variant that lets an edge terminate without ever holding the private key, what Encrypted Client Hello does and does not hide from the terminator, and finally the trust boundary, which is the part most teams get wrong.

Why the handshake ends at the edge

A TLS handshake is expensive in the one currency the web cannot print more of: round trips. Even TLS 1.3, which cut the handshake to a single round trip for a fresh connection and zero for a resumed one, still pays for the underlying TCP setup and the physics of light through fiber. If your origin lives in one datacenter and your users are spread across continents, every new connection eats a transcontinental round trip before a single byte of application data moves. Terminate that handshake at a point of presence near the user and the expensive part happens locally. The long-haul leg to origin gets reused, kept warm, and amortized across many users.

So the load balancer or CDN edge does the cryptographic work. It performs the handshake, presents a certificate that chains to a public CA the browser already trusts, proves possession of the matching private key, and derives session keys with the client. From that point the edge holds the symmetric keys for your connection. It decrypts your request into plaintext. Only then does it decide what to do with the long leg to origin, and that decision is a configuration choice, not a law of physics.

This is the same machinery whether the box is called a load balancer, a reverse proxy, an application delivery controller, or a CDN edge. The vendor names differ. The mechanism is identical: someone other than the origin owns the client-facing private key and does the decryption. That someone sits inside the encrypted channel, which is the whole point and the whole problem.

The performance argument compounds with anycast routing and a global CDN footprint. Anycast steers the client’s packets to the nearest advertisement of the destination IP, which lands them on a nearby edge, which is exactly where you want the handshake to terminate. The two techniques are co-designed. Termination near the user only helps if routing put the user near a terminator in the first place.

The two-handshake path

Picture the request as two separate TLS sessions stitched together by a proxy that can read the seam.

Client browser Edge proxy CDN / LB holds session keys Origin your server TLS session A your ClientHello TLS session B edge's ClientHello plaintext exists here, between the two sessions *Two independent TLS sessions joined at the edge. The proxy decrypts session A to plaintext, then re-encrypts into session B. The origin's view of "the client" is session B.*

Session A is the one your browser negotiates with the edge. Your ClientHello, your cipher preferences, your TLS extensions, your SNI, all of it lands on the edge. The edge replies with the certificate for the hostname you requested, finishes the handshake, and now holds the keys to read session A.

Session B is whatever the edge opens toward origin. It might be a fresh TLS handshake the edge initiates as a client, carrying the edge’s own ClientHello, not yours. It might be plain HTTP. It might be a long-lived connection reused across hundreds of unrelated client requests. The origin negotiates session B with the edge and only the edge. As far as the origin’s TLS stack is concerned, its peer is a datacenter IP belonging to the CDN, speaking the CDN’s TLS dialect.

Between the two sits a window of plaintext. The request is decrypted out of session A and re-encrypted into session B, and in that gap it is readable by the proxy. This is what lets the edge route by path, cache responses, run a WAF, rewrite headers, inject a bot-detection script, or apply a cache key. None of that is possible without breaking the end-to-end encryption the padlock implies. The padlock was always a statement about session A, never about the whole path.

The relationship between the two sessions is not one-to-one. A single session B can carry the requests of many distinct clients. The edge keeps a pool of warm connections to your origin and multiplexes unrelated users over them, which is the whole reason termination pays off: the costly origin handshake is set up once and reused thousands of times. With HTTP/2 between edge and origin, several requests share one connection through stream multiplexing, and a CDN may also coalesce requests for different hostnames that resolve to the same origin certificate onto the same connection. From the origin’s side, the boundaries between users have dissolved. What arrives is a stream of requests over a connection the origin negotiated with one peer, the edge, tagged with headers that claim to describe the many clients hiding behind it.

This many-to-one collapse is the structural reason the origin’s view of “the client” is so impoverished. The origin has one TCP/IP path to fingerprint, one TLS handshake, one set of negotiated parameters, and they all belong to the edge. Per-client distinctions, the very thing detection wants, exist only on the far side of the termination point. If the origin wants them it has to receive them as forwarded data, and the structure of who forwards what is the next thing to pin down.

What the origin actually sees

Here is the consequence that matters most for anyone doing detection or abuse analysis. The origin cannot fingerprint the client’s TLS, because the origin never received the client’s ClientHello.

Server-side TLS fingerprinting works by hashing the immutable bits of a ClientHello: the ordered cipher suite list, the extension list, the supported groups, and so on. JA3 took an MD5 over five fields from the ClientHello, and JA4 refined and extended that into a family of fingerprints. The technique only works if you are the party that received the ClientHello. Behind a terminating proxy, the origin is not that party. The ClientHello that reaches the origin’s socket is the edge’s, and every request that came through that edge carries the same handshake. Thousands of distinct clients collapse onto one signature. The entropy that made fingerprinting useful is gone by the time bytes reach origin.

So the origin has two choices. It can give up on TLS fingerprinting entirely and rely on the edge to do bot scoring, which is exactly what CDN bot products do. Or it can ask the edge to compute the fingerprint at the moment of termination, while the real ClientHello is still in hand, and forward the result as data.

That second path is why CDNs expose TLS metadata as request fields and headers. On Cloudflare, a Worker running at the edge can read a cf object on the request that carries tlsVersion (for example TLSv1.3), tlsCipher (for example AEAD-AES128-GCM-SHA256), tlsClientHelloLength, and tlsClientAuth for mTLS details. The JA3 and JA4 fingerprints live under a botManagement object as ja3Hash and ja4, and those are only populated for Enterprise customers who have purchased Bot Management. The origin does not compute these. The edge does, because only the edge saw the handshake, and the values ride forward from there.

client edge (sees real ClientHello) origin ciphers extensions groups ALPN, SNI hash → ja3 / ja4 terminate TLS reads header, trusts the edge cf.botManagement.ja3Hash / ja4 x-amzn-tls-version / x-amzn-tls-cipher-suite the fingerprint becomes forwarded data, not an observed property *Once TLS terminates at the edge, the client fingerprint is data the edge forwards, not something the origin can measure. The origin's trust in it is exactly its trust in the edge.*

AWS does the same with HTTP headers rather than a scripting object. An Application Load Balancer that terminates TLS adds x-amzn-tls-version, carrying the negotiated TLS protocol version, and x-amzn-tls-cipher-suite, carrying the negotiated cipher suite, to the request before it forwards to the target. It also adds the familiar reverse-proxy trio: X-Forwarded-For with the client IP, X-Forwarded-Proto with the protocol the client used (https even though the leg to the target may be plain HTTP), and X-Forwarded-Port. For mTLS, the client certificate details are surfaced in headers too. Without these, the target has no way to know whether the original connection was even encrypted, because by the time the request reaches it, the original TLS is long gone.

The deep point is that every property of the client connection that the origin wants, from the negotiated cipher to the source IP to the JA4 hash, has been demoted from an observed fact to a claim made by the edge. The origin reads a header. The header says the client did X. Whether that is true depends entirely on whether the edge is honest and whether nothing else can write that header. Hold that thought, because it is the trust boundary, and we will come back to it.

This is also why client-side TLS impersonation aims at the edge, not the origin. A scraper using a library like uTLS or curl-impersonate to forge a browser ClientHello is trying to fool the party that actually receives the ClientHello, which behind a CDN is the CDN. The origin is downstream of a verdict that was already rendered. The split between server-side and client-side detection maps cleanly onto the two-handshake path: the network-layer fingerprint is decided at session A’s terminator, and everything after is JavaScript and headers.

Encryption modes and the leg to origin

Whether session B is encrypted at all is a setting, and the names CDNs give those settings are worth knowing because they describe real differences in who can read your traffic and where.

Cloudflare’s SSL/TLS modes are the clearest published taxonomy. In Off, there is no HTTPS at any point and everything is cleartext. In Flexible, the edge serves HTTPS to the browser but talks plain HTTP to origin, so the long leg is unencrypted. This is the mode that produces a green padlock over an unencrypted origin connection, which is exactly as misleading as it sounds, and Cloudflare recommends against it. In Full, the edge speaks HTTPS to origin but does not validate the origin’s certificate, so a self-signed cert is accepted and an active attacker between edge and origin could impersonate the origin. In Full (strict), the edge speaks HTTPS to origin and validates the origin certificate against a public CA or against Cloudflare’s own Origin CA, and a mismatch surfaces to the visitor as a 526 error. There is also Strict (SSL-Only Origin Pull), where the edge always connects to origin over HTTPS regardless of how the browser connected.

browser → edge edge → origin origin cert checked Flexible encrypted cleartext n/a Full encrypted encrypted no Full (strict) encrypted encrypted yes Off cleartext cleartext n/a the padlock the user sees only describes the first column *The browser's padlock reflects the browser-to-edge leg only. Whether the edge-to-origin leg is encrypted, and whether the origin's identity is verified, is a separate configuration the user never sees.*

The Full versus Full (strict) distinction is more than pedantry. Full encrypts the origin leg but accepts any certificate, which stops passive eavesdropping but not an active machine-in-the-middle who can present their own cert. Full (strict) closes that gap by pinning the origin to a CA-validated identity. For an origin that only ever receives traffic from the CDN, Cloudflare’s Origin CA issues a certificate trusted by the edge but not by public browsers, which is fine precisely because no browser ever connects to the origin directly.

The architectural lesson is that “encrypted” is not a single bit. There is the browser-to-edge leg, the edge-to-origin leg, and the question of whether each peer’s identity was verified. A site can be HTTPS to the user and HTTP to the origin at the same time. The user has no way to tell, because nothing in the browser surfaces the second leg.

There is a symmetric version of origin verification that strengthens the leg further. In the modes above, the edge checks the origin’s certificate, but the origin does not check the edge. The origin accepts session B from whoever connects to port 443 with a valid TLS handshake. If the origin is publicly reachable, that includes attackers. Mutual TLS between edge and origin flips the asymmetry: the edge presents a client certificate that the origin validates, so the origin only completes session B with a peer that holds the CDN’s client cert. Cloudflare’s authenticated origin pull and the equivalent on other CDNs work this way. It turns “anyone who can reach the origin” into “the specific party holding this key,” which is a far stronger guarantee than encryption alone. We will see why that distinction is load-bearing when we get to the trust boundary.

Terminating without holding the key

There is a wrinkle that complicates the simple story of “the edge holds your private key.” For some operators, especially banks and other regulated institutions, handing the TLS private key to a third party is a non-starter. Losing control of a private key can itself be a reportable security event.

Cloudflare’s answer, shipped in 2014, is Keyless SSL. The idea grew out of meetings with financial institutions in late 2012, after a wave of large DDoS attacks against US banks pushed them toward cloud protection while their compliance posture forbade surrendering keys. The trick is that only one operation in the handshake actually requires the private key: the signing (or, in the older RSA key exchange, the decryption) step that proves possession of the key and contributes to the session secret. Everything else, the symmetric crypto and the bulk of the handshake, does not need the key.

So Keyless SSL splits that one operation off. The edge runs the handshake but, when it reaches the step that needs the private key, sends the relevant material over a secure channel to a key server the customer hosts. The customer’s key server performs the private-key operation and returns the result. The edge derives session keys and continues. The private key never leaves the customer’s premises. The edge still terminates TLS, still sees your plaintext, still holds the session keys for your connection, but it never holds the long-term private key. Cloudflare reported keyless clients seeing roughly 3x faster TLS termination than an on-premise-only setup, because the cheap symmetric work still happens at the edge near the user.

Keyless SSL is a precise illustration of what termination is and is not. It removes one specific risk, exposure of the long-term private key, while leaving the fundamental property intact: the edge is inside your encrypted channel and reads your cleartext. If your threat model is “I cannot let a vendor hold my signing key,” Keyless solves it. If your threat model is “I cannot let a vendor read my plaintext,” Keyless does nothing, because termination at the edge means plaintext at the edge by definition.

What ECH hides, and from whom

A natural question: doesn’t Encrypted Client Hello fix the privacy gap? ECH encrypts the ClientHello, including the SNI, so a network observer can no longer see which site you are visiting from the handshake. It is the successor to the abandoned ESNI and it closes a real metadata leak.

But read carefully what ECH protects against. It hides the inner ClientHello from observers on the wire between the client and the terminating edge. It does not hide anything from the edge itself. The ECH design splits the ClientHello into an outer part, which carries a public-facing “outer SNI,” and an inner part, which is encrypted and carries the real “inner SNI.” On Cloudflare, the shared outer SNI is cloudflare-ech.com, so on the wire every ECH-enabled Cloudflare site looks like a connection to that same generic name. The inner SNI is encrypted with a public key that only the edge can decrypt. The edge opens the inner ClientHello, reads the real hostname, and routes accordingly.

client edge decrypts inner SNI outer SNI: cloudflare-ech.com inner SNI: real-site.example (encrypted) on-path observer sees only the outer name the edge sees both *ECH encrypts the inner SNI against on-path observers. The terminating edge holds the decryption key and reads the real hostname, because it must, in order to route. ECH narrows the set of who can see the destination; it does not remove the terminator from that set.*

ECH narrows the set of parties who learn which site you visited, from “anyone on the path” down to you, the edge, and the site owner. That is a genuine privacy win against passive surveillance and against DNS-based filtering that relied on cleartext SNI. It is not a change to the termination model. The terminator still terminates. ECH and edge termination are orthogonal: one hides the destination from the network, the other hands the plaintext to the edge. A site can have both, and the edge still reads everything.

The trust boundary

Now the part that gets teams in trouble.

When the edge forwards a header like X-Forwarded-For: 203.0.113.7 or a JA4 hash or X-Forwarded-Proto: https, the origin is being told a fact about a connection it never saw. The only thing that makes that header trustworthy is the guarantee that nothing untrusted could have written it. That guarantee has two halves, and both have to hold.

The first half: the edge must overwrite, not append blindly. X-Forwarded-For is a list, and a client can send its own X-Forwarded-For in the original request. AWS ALB defaults to append mode, where the load balancer adds the real client IP to whatever the client sent, producing a comma-separated list. If your application reads the leftmost entry as “the client,” a client who sends X-Forwarded-For: 127.0.0.1 has just handed you a value you will mistake for trusted infrastructure. The correct parse is to count trusted hops from the right, starting from the IP your own proxy inserted and walking left only across proxies you control. Read from the left and you are trusting the attacker. This is a recurring source of access-control and rate-limit bypasses, and it is not theoretical; misconfigured forwarded-header trust has produced real auth-bypass advisories in widely used proxies.

The second half is harder and more often missed: the origin must be unreachable except through the edge. If the origin has a public IP that an attacker can connect to directly, the entire forwarded-header scheme collapses. The attacker connects straight to origin, sets X-Forwarded-For to whatever they like, sets X-Forwarded-Proto: https to fake an encrypted origin connection, supplies a forged JA4 header to impersonate a known-good client, and the origin, trusting headers it should only trust from the edge, believes all of it. The fingerprint the edge worked to compute is now attacker-controlled, because the attacker bypassed the only party that could have computed it honestly.

edge sets trusted headers origin trusts the headers intended path attacker forges XFF, proto, JA4 direct if origin is reachable directly, every forwarded header is attacker-controlled *The forwarded-header scheme assumes the edge is the only way in. A directly reachable origin lets an attacker forge every value the origin was told to trust, including the TLS fingerprint the edge was supposed to vouch for.*

This is why origin lockdown matters as much as the encryption mode. Cloudflare’s Strict origin pull, IP allowlists that only admit the CDN’s published ranges, mutual TLS between edge and origin, or a private tunnel that gives the origin no public IP at all, are all ways of enforcing “the edge is the only door.” Without that enforcement, Full (strict) protects the confidentiality of the edge-to-origin leg while doing nothing to stop an attacker who simply walks around it. Encryption of the leg and authentication of who may use the leg are different problems, and you need both.

There is a quieter version of the same trust question that has nothing to do with attackers. The edge can read your plaintext. That is not a vulnerability, it is the contract. When you put a CDN in front of your site, you are extending your trust boundary to include that CDN’s infrastructure and the jurisdictions it operates in. For most sites that is an easy trade. For a site handling regulated data it is a deliberate decision with a paper trail, which is exactly why Keyless SSL and bring-your-own-key arrangements exist. The handshake ending at the edge is not just a performance fact. It is a statement about who is inside your circle of trust, and that statement is true for every byte that flows through.

Where this leaves you

The padlock means less than the marketing suggested and exactly what the protocol always said. It attests to one TLS session, the one between your browser and whatever box answered the handshake. For most of the web that box is a CDN edge, your traffic is decrypted there, and a second connection, encrypted or not, verified or not, carries it the rest of the way. The origin that owns your data sees a connection from a datacenter, speaking the CDN’s TLS dialect, annotated with a stack of headers that describe a client the origin never met.

Everything downstream follows from that. Bot detection on the network layer has to happen at the terminator, because only the terminator holds the real ClientHello, which is why CDN bot products live at the edge and why client-side TLS impersonation targets the edge. Fingerprints reach the origin as forwarded claims, no stronger than the channel that delivered them and the lockdown that keeps anyone else from forging them. Encryption of the origin leg is a separate setting from encryption of the browser leg, and both are separate from whether either peer’s identity was checked. ECH hides your destination from the network but not from the terminator. And Keyless SSL proves the cleanest version of the rule: you can keep your private key and still hand the edge your plaintext, because termination was never about the key, it was about the cleartext.

If you run an origin behind a CDN, the single most consequential line in your config is not the cipher list. It is whatever makes the edge the only way in. Get that wrong and the entire careful apparatus, the strict cert validation, the JA4 forwarding, the rate limits keyed on client IP, becomes a set of suggestions an attacker is free to ignore by knocking on the door the edge was supposed to be guarding.


Sources & further reading

Further reading