Cloudflare is, by almost any measure, one of the most impressive pieces of internet infrastructure ever built. It absorbs some of the largest DDoS attacks ever recorded without breaking a sweat, serves cached content from hundreds of cities so your users feel like the server is next door, and bundles a genuinely strong WAF, bot management, TLS automation, and DNS into a product that a single engineer can configure in an afternoon. For the overwhelming majority of teams, putting Cloudflare in front of an application is a net security improvement, not a regression. None of what follows should be read as a reason to rip it out.

But every reverse proxy makes a trade, and Cloudflare is no exception. To do most of what makes it valuable—caching, inspecting requests for attacks, rewriting headers, routing intelligently—it has to see your traffic in the clear. That means the encrypted tunnel from your user's browser ends at Cloudflare's edge, not at your origin. For a window of time, the plaintext of every request and response lives inside infrastructure you neither own nor audit. This article looks honestly at what that exposure is, how much it should worry you, and how to shrink the blast radius with two complementary techniques: application‑level encryption and DPoP (Demonstrating Proof‑of‑Possession).

What Cloudflare Does Brilliantly

It is worth being concrete about the upside before discussing the downside, because the goal here is calibration, not alarmism:

  • Edge performance: global anycast and caching put content milliseconds from users, and the TLS handshake itself terminates close to them.
  • Attack absorption: volumetric DDoS, credential‑stuffing, and a large class of application‑layer attacks are filtered before they ever reach your origin.
  • Operational simplicity: automatic certificate issuance and renewal, HTTP/3, and sane defaults remove entire categories of misconfiguration.
  • A mature security organization: Cloudflare invests heavily in internal controls, and there is no public evidence of systemic abuse of customer plaintext. The concern below is structural, not an accusation.

The Architectural Reality: TLS Terminates at the Edge

When a browser connects to a site behind Cloudflare, the TLS session is established with Cloudflare's edge server. Cloudflare decrypts the request, does its work, and then opens a second connection to your origin. Cloudflare's SSL/TLS modes describe only that second hop:

  1. Flexible: edge‑to‑origin is plain HTTP. Avoid this.
  2. Full: edge‑to‑origin is re‑encrypted, but the origin certificate is not validated.
  3. Full (Strict): edge‑to‑origin is re‑encrypted and the origin certificate is validated. This is the mode you should use.

The crucial point is that none of these modes change the edge behavior. Even in Full (Strict), Cloudflare decrypts the user's TLS session at the edge—it has to, in order to cache, inspect, and route. So between the inbound decryption and the outbound re‑encryption, the request body, the URL, the query string, the Cookie header, and the Authorization header all exist as plaintext in Cloudflare's memory and, depending on configuration, in its logs. This is not a Cloudflare quirk; it is true of any proxy that performs content‑aware work. It is the price of the features.

Risk Assessment

Calibrating this risk means separating likelihood from impact, and being precise about what is actually exposed.

What is exposed at the edge:

  • Request and response bodies (form posts, JSON payloads, API responses).
  • Session cookies and bearer tokens carried in headers.
  • Full URLs and query parameters, which frequently leak identifiers and, badly, sometimes tokens.

Plausible exposure paths, roughly ordered by likelihood:

  1. Your own logging misconfiguration. The most common real‑world leak is not the provider—it is a team enabling verbose request logging, Logpush jobs, or a Worker that captures bodies, and then shipping that plaintext to a storage bucket or SIEM that is itself poorly secured. This is firmly within your control and is the highest‑probability event.
  2. A compromised or rogue insider at any intermediary with access to edge systems. Mature providers have strong controls here, so probability is low—but it is non‑zero, and it is a trust assumption you are making implicitly.
  3. A bug or incident that causes memory contents to be exposed. The industry has seen edge‑provider memory‑disclosure bugs before; they are rare but real, and when they happen they can expose other tenants' plaintext.
  4. Lawful compulsion or jurisdictional reach over an intermediary that holds your plaintext, depending on where you and your users sit.

The honest summary: for most data, the probability is low and Cloudflare's controls are good, so blanket panic is unwarranted. But the impact of plaintext credentials or regulated personal data leaking is severe, and a zero‑trust posture says you should not let any single intermediary's good behavior be the only thing standing between an attacker and your users' secrets. Where the data is sensitive enough—authentication material, financial instructions, health data, anything whose disclosure is catastrophic—defense in depth is justified.

“Transport encryption protects data between two endpoints. The moment a proxy is one of those endpoints, ‘end‑to‑end’ quietly became ‘end‑to‑middle.’ If the payload itself matters, encrypt the payload itself.”

The Defense‑in‑Depth Answer

Two techniques, used together, close most of the gap without giving up any of Cloudflare's benefits. Keep TLS exactly as it is—this layers on top of it.

  1. Application‑level encryption (ALE): encrypt the sensitive parts of a request body in the browser using a key that only your origin holds. Cloudflare then sees only ciphertext in the body; it can still cache, route, and run its WAF on everything else, but the secret payload is opaque to it.
  2. DPoP token binding: bind your access tokens to a private key that never leaves the browser. Even if a token is captured—from a log, from memory, from a rogue insider—it cannot be replayed, because every request must carry a fresh signature that only the holder of the private key can produce.

ALE protects the data; DPoP protects the credential. The first stops a leaked body from being readable; the second stops a leaked token from being usable. You need both because ALE alone does nothing for the cookie in the header, and DPoP alone does nothing for the personal data in the body.

Part 1 — Application‑Level Encryption

The pattern is hybrid encryption. The browser generates a one‑time AES‑256‑GCM key, encrypts the payload with it, then wraps that AES key with your origin's RSA public key (RSA‑OAEP). Only the origin holds the RSA private key, so only the origin can unwrap the AES key and read the body. The edge sees three opaque blobs.

Browser (JavaScript, Web Crypto API)

const enc = new TextEncoder();

const b64url = (bytes) =>
  btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// Import the origin's RSA public key (distributed as base64 SPKI/DER).
async function importServerKey(spkiB64) {
  const der = Uint8Array.from(atob(spkiB64), (c) => c.charCodeAt(0));
  return crypto.subtle.importKey(
    "spki", der,
    { name: "RSA-OAEP", hash: "SHA-256" },
    false, ["encrypt"]
  );
}

// Encrypt a JSON object so only the origin can read it.
async function sealPayload(serverPubKey, obj) {
  const aesKey = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 }, true, ["encrypt"]
  );
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, aesKey, enc.encode(JSON.stringify(obj))
  );

  const rawAes  = await crypto.subtle.exportKey("raw", aesKey);
  const wrapped = await crypto.subtle.encrypt(
    { name: "RSA-OAEP" }, serverPubKey, rawAes
  );

  // Note: Web Crypto AES-GCM appends the 16-byte auth tag to `ct`.
  return {
    k:  b64url(wrapped), // RSA-OAEP-wrapped AES key
    iv: b64url(iv),
    ct: b64url(ct)       // ciphertext || 16-byte GCM tag
  };
}

Origin (.NET 8 / ASP.NET Core)

using System.Security.Cryptography;
using System.Text.Json;

public sealed record SealedPayload(string k, string iv, string ct);

public static class Base64Url
{
    public static byte[] Decode(string s)
    {
        s = s.Replace('-', '+').Replace('_', '/');
        s += (s.Length % 4) switch { 2 => "==", 3 => "=", _ => "" };
        return Convert.FromBase64String(s);
    }

    public static string Encode(ReadOnlySpan<byte> b) =>
        Convert.ToBase64String(b).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}

public sealed class PayloadCrypto(RSA originPrivateKey) // RSA key lives ONLY at the origin
{
    public T Open<T>(SealedPayload p)
    {
        byte[] wrappedKey = Base64Url.Decode(p.k);
        byte[] iv         = Base64Url.Decode(p.iv);
        byte[] ctAndTag   = Base64Url.Decode(p.ct);

        // 1) Unwrap the per-message AES key. The edge never had this private key.
        byte[] aesKey = originPrivateKey.Decrypt(wrappedKey, RSAEncryptionPadding.OaepSHA256);

        // 2) Web Crypto appended the 16-byte GCM tag to the ciphertext — split it back out.
        const int tagLen = 16;
        ReadOnlySpan<byte> ct  = ctAndTag.AsSpan(0, ctAndTag.Length - tagLen);
        ReadOnlySpan<byte> tag = ctAndTag.AsSpan(ctAndTag.Length - tagLen);
        var pt = new byte[ct.Length];

        // .NET 8 requires an explicit tag size for AesGcm (SYSLIB0053).
        using var gcm = new AesGcm(aesKey, tagLen);
        gcm.Decrypt(iv, ct, tag, pt);

        return JsonSerializer.Deserialize<T>(pt)!;
    }
}

With this in place, a request body captured at the edge is just random bytes. Note what ALE does not cover: the URL, the method, and the headers are still visible. That is exactly why credentials need a separate defense.

Part 2 — Binding Tokens with DPoP

DPoP (RFC 9449) makes a token useless to anyone who does not also possess a private key held by the legitimate client. The browser generates an ECDSA P‑256 key pair; the access token is issued bound to the public key's thumbprint. On every request, the client attaches a short‑lived signed JWT—the DPoP proof—that names the HTTP method and URL it is being used for. A stolen token cannot be replayed, because the attacker cannot forge a valid proof.

A critical detail: generate the private key as non‑extractable. In Web Crypto, the public key of an EC pair remains exportable even when the private key is not, so you still get the JWK you need for the proof header—but the private key can never be read out of the browser and exfiltrated for offline replay. Store the key handle in IndexedDB (CryptoKey objects are structured‑cloneable).

Browser (JavaScript, Web Crypto API)

// Generate once; private key is NON-extractable, public key stays exportable.
async function createDpopKeyPair() {
  return crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    false,                 // private key cannot be exported / stolen for offline use
    ["sign", "verify"]
  );
  // Persist the returned pair in IndexedDB; do not keep it in localStorage.
}

async function dpopProof(keyPair, method, url, accessToken) {
  const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);

  const header = {
    typ: "dpop+jwt", alg: "ES256",
    jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }
  };
  const payload = {
    jti: b64url(crypto.getRandomValues(new Uint8Array(16))),
    htm: method,
    htu: url.split("?")[0],
    iat: Math.floor(Date.now() / 1000)
  };
  if (accessToken) {
    const ath = await crypto.subtle.digest("SHA-256", enc.encode(accessToken));
    payload.ath = b64url(ath); // binds the proof to this specific token
  }

  const signingInput =
    b64url(enc.encode(JSON.stringify(header))) + "." +
    b64url(enc.encode(JSON.stringify(payload)));

  const sig = await crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    keyPair.privateKey,
    enc.encode(signingInput)
  );
  // Web Crypto emits raw r||s, which is exactly the JOSE ES256 format.
  return signingInput + "." + b64url(sig);
}

// One wrapper that does both: encrypt the body AND prove possession of the token.
async function securePost(url, body, accessToken, serverPubKey, dpopKeys) {
  const sealed = await sealPayload(serverPubKey, body);
  const proof  = await dpopProof(dpopKeys, "POST", url, accessToken);
  return fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `DPoP ${accessToken}`,
      "DPoP": proof
    },
    body: JSON.stringify(sealed)
  });
}

Origin (.NET 8) — validating the proof

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public sealed class DpopValidator
{
    // Replace with a distributed, expiring store (Redis) in production.
    private readonly HashSet<string> _seenJti = new();

    /// <summary>Returns the JWK thumbprint (jkt) on success; throws on any failure.</summary>
    public string Validate(string dpopHeader, string method, string htu, string accessToken)
    {
        var parts = dpopHeader.Split('.');
        if (parts.Length != 3) throw new InvalidOperationException("malformed DPoP");

        var header  = JsonSerializer.Deserialize<JsonElement>(Base64Url.Decode(parts[0]));
        var payload = JsonSerializer.Deserialize<JsonElement>(Base64Url.Decode(parts[1]));
        var sig     = Base64Url.Decode(parts[2]);

        if (header.GetProperty("typ").GetString() != "dpop+jwt") throw new("wrong typ");
        if (header.GetProperty("alg").GetString() != "ES256")    throw new("unexpected alg");

        // 1) Rebuild the public key from the embedded JWK and verify the JWS signature.
        var jwk = header.GetProperty("jwk");
        using var ecdsa = ECDsa.Create(new ECParameters
        {
            Curve = ECCurve.NamedCurves.nistP256,
            Q = new ECPoint
            {
                X = Base64Url.Decode(jwk.GetProperty("x").GetString()!),
                Y = Base64Url.Decode(jwk.GetProperty("y").GetString()!)
            }
        });
        var signingInput = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
        // Default format is IEEE P1363 (r||s) — matches Web Crypto output.
        if (!ecdsa.VerifyData(signingInput, sig, HashAlgorithmName.SHA256))
            throw new("bad DPoP signature");

        // 2) Bind the proof to THIS request.
        if (payload.GetProperty("htm").GetString() != method) throw new("htm mismatch");
        if (!UriPathEquals(payload.GetProperty("htu").GetString()!, htu)) throw new("htu mismatch");

        // 3) Reject stale or replayed proofs.
        var iat = payload.GetProperty("iat").GetInt64();
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - iat) > 30) throw new("stale proof");
        var jti = payload.GetProperty("jti").GetString()!;
        lock (_seenJti) { if (!_seenJti.Add(jti)) throw new("replayed jti"); }

        // 4) Bind the proof to the presented token: ath == base64url(SHA-256(token)).
        var ath = Base64Url.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
        if (payload.GetProperty("ath").GetString() != ath) throw new("ath mismatch");

        // 5) Return the RFC 7638 thumbprint; the caller compares it to cnf.jkt in the token.
        var canonical =
            $"{{\"crv\":\"{jwk.GetProperty("crv").GetString()}\"," +
            $"\"kty\":\"{jwk.GetProperty("kty").GetString()}\"," +
            $"\"x\":\"{jwk.GetProperty("x").GetString()}\"," +
            $"\"y\":\"{jwk.GetProperty("y").GetString()}\"}}";
        return Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
    }

    private static bool UriPathEquals(string a, string b) =>
        Uri.TryCreate(a, UriKind.Absolute, out var ua) &&
        Uri.TryCreate(b, UriKind.Absolute, out var ub) &&
        ua.GetLeftPart(UriPartial.Path) == ub.GetLeftPart(UriPartial.Path);
}

Wiring it into a Minimal API endpoint

app.MapPost("/api/transfer",
    (HttpRequest req, SealedPayload body, PayloadCrypto crypto, DpopValidator dpop) =>
{
    var auth  = req.Headers.Authorization.ToString();          // "DPoP <token>"
    var token = auth["DPoP ".Length..];
    var url   = $"{req.Scheme}://{req.Host}{req.Path}";

    // Proves the caller holds the bound private key — a leaked token alone fails here.
    var jkt = dpop.Validate(req.Headers["DPoP"]!, "POST", url, token);
    // TODO: assert jkt == the cnf.jkt claim inside the validated access token.

    // Plaintext exists ONLY here, at the origin — never at the edge.
    var data = crypto.Open<TransferRequest>(body);
    // ... process the request ...
    return Results.Ok();
});

Caveats — What This Does and Does Not Solve

  • It is not a replacement for TLS. Keep Full (Strict) mode and HSTS. ALE and DPoP are an additional layer for high‑value data, not a substitute for transport security.
  • ALE protects bodies, not metadata. URLs, methods, timing, and sizes are still visible to the edge. Do not put secrets in query strings, and be mindful that traffic analysis remains possible.
  • DPoP narrows but does not eliminate XSS risk. A non‑extractable key cannot be stolen for offline replay, which is a large improvement over bearer tokens and cookies. But an active XSS payload can still use the key while the page is open. Pair DPoP with a strong Content‑Security‑Policy.
  • Key management is the hard part. The origin's RSA key must be protected (ideally in an HSM or KMS), rotated, and distributed to clients with integrity. ALE is worth the operational cost for the most sensitive fields, not necessarily for every byte.
  • This is zero‑trust hygiene, not an accusation. The point is to avoid making any single intermediary's good behavior the only safeguard for your users' secrets.

Actionable Steps for Engineering Leaders

  • Inventory which endpoints carry truly sensitive payloads (auth, payments, PII, health) and scope ALE to those rather than the whole surface.
  • Audit your own logging first—Logpush, Workers, SIEM ingestion—since self‑inflicted body logging is the most likely real leak.
  • Adopt DPoP‑bound tokens for high‑value sessions so a captured token is inert without the client's private key.
  • Store DPoP keys as non‑extractable CryptoKeys in IndexedDB and enforce a strict CSP to contain XSS.
  • Keep edge‑to‑origin on Full (Strict), protect the origin RSA key in a KMS/HSM, and define a rotation schedule.

Conclusion

Cloudflare earns its place in front of your application, and for most teams it makes them safer, not less safe. But the same architecture that lets it cache, filter, and accelerate also means your plaintext briefly lives where you cannot see it. For data whose disclosure would be catastrophic, the mature response is not to distrust the provider loudly—it is to design so that trust is not required. Encrypt the payload at the application layer so a leaked body is unreadable, and bind your tokens with DPoP so a leaked credential is unusable. Do both, keep TLS exactly as it is, and the edge keeps every advantage it offers while losing its access to your secrets.