On a customer's ASP.NET Core service, network traffic was running far higher than the data actually being served should account for. That mismatch, not a support ticket or an error log, was the first sign something was off. The service already had AddResponseCompression() wired up, a MimeTypes list covering the JSON and JavaScript responses, UseResponseCompression() in the pipeline, exactly the way the documentation shows it. So the obvious next step was to confirm compression wasn't already doing its job: capture a HAR file of real traffic and search it for Content-Encoding.
Nothing. Not one response, out of dozens, was compressed. The client had sent Accept-Encoding: gzip, deflate, br. The middleware was registered. The MIME type was on the list. Every response body still crossed the wire uncompressed.
There's a specific reason, and it comes down to one property in Microsoft.AspNetCore.ResponseCompression, quietly defeating compression on the one transport almost every production service uses: HTTPS.
The One Property Nobody Sets
ResponseCompressionOptions has a property called EnableForHttps. It defaults to false.
builder.Services.AddResponseCompression(options =>
{
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/json",
"text/javascript",
});
// EnableForHttps is false here, silently
});
Nothing above is wrong. UseResponseCompression() is wired up correctly, and the MIME type list covers exactly what's being served. Test this over plain HTTP, locally or through an in-process test host, and it behaves exactly as configured. The moment the same request arrives over HTTPS, ResponseCompressionMiddleware checks HttpContext.Request.IsHttps, sees true, and skips compression outright: no warning, no log line, no failed health check. It serves the response uncompressed, as if the middleware were never registered.
Why This Default Exists: BREACH
This is a deliberate default, not an oversight, and it traces back to a specific 2013 attack: BREACH.
BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) targets HTTP-level compression over TLS. The short version: if a response mixes attacker-influenced input, say a value reflected from a query string or a search box, with a secret the attacker wants (a CSRF token, a session identifier, an API key embedded in the page), compression itself becomes a side channel. Compressed size shrinks further whenever the attacker's guessed substring matches part of the secret, because gzip and its relatives deduplicate repeated text. By sending many requests, each with a different guessed substring, and watching how the encrypted response length changes, an attacker can recover the secret one byte at a time, without ever breaking the encryption itself.
TLS hides a response's contents. It doesn't hide its length, and compression turns that length into a signal.
BREACH followed CRIME, an earlier attack against compression built into TLS itself, which got that feature removed from the protocol entirely. BREACH moved the same idea up one layer, to the HTTP response body, exactly what ResponseCompressionMiddleware produces. Rather than detect the vulnerable pattern, reflected input plus a secret in the same compressed response, case by case, ASP.NET Core's maintainers made the whole feature opt-in over HTTPS. Blunt, but reliable: no compression, no oracle.
Why Nothing You Check Locally Catches This
Local development runs over plain HTTP. So does most container-to-container traffic behind a load balancer. IsHttps is false, so compression behaves exactly as configured, and everything visible on a laptop looks correct.
WebApplicationFactory defaults to HTTP too. ASP.NET Core's in-memory integration test host hands you an HttpClient whose BaseAddress is http://localhost, unless you override it. A test like this passes and tells you nothing about production:
var client = factory.CreateClient(); // BaseAddress: http://localhost
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
var response = await client.GetAsync("/api/data");
Assert.Contains("gzip", response.Content.Headers.ContentEncoding); // passes
The assertion is real, and the test does exercise the compression middleware. It just never touches the one branch of that middleware, the HTTPS check, that decides whether compression runs at all once a client connects over HTTPS.
The harder part: most services don't terminate TLS themselves. In a typical cloud-native setup, TLS ends at an ingress, a load balancer, or a reverse proxy in front of the pods, and traffic between that edge and the application is often plain HTTP. So why does IsHttps still come back true deep inside the app? UseForwardedHeaders(). It reads X-Forwarded-Proto from the trusted edge and rewrites HttpContext.Request.Scheme to match what the client used, exactly what you want for correct redirects, cookies, and URL generation. But it also means IsHttps reflects the client-facing scheme, not the transport the process is literally listening on. Run UseForwardedHeaders() before UseResponseCompression(), which the pipeline usually should for those other reasons, and compression sees https and switches itself off, even on a socket that's plain HTTP end to end.
Every signal you can inspect from a laptop, a unit test, or a container health check says compression is on. Only the client-facing traffic disagrees.
Finding It: Capture Real Traffic
Code review won't catch this: the code is correct for what it configures. Local tests won't catch it either, for the reasons above. The only reliable check is to look at what real clients receive over the real protocol.
Capture a HAR of genuine traffic against whatever environment your users hit (a browser's network panel, "Save all as HAR", or an existing session capture), then check it with a script instead of scanning dozens of requests by eye:
# Using jq against an exported .har file
jq '[.log.entries[] | select(.request.url | startswith("https://your-service"))] | length' traffic.har
jq '[.log.entries[] | select(.request.url | startswith("https://your-service"))
| select(.response.headers[] | select(.name | test("content-encoding";"i")))] | length' traffic.har
If the first number matches your total request count and the second comes back zero, this is your bug: HTTPS traffic, zero Content-Encoding headers, no matter what the middleware configuration claims. It's a five-minute check, far more conclusive than reading configuration code, and the technique holds well beyond this one bug. Whenever "the config looks right, but I can't confirm it's live in production" comes up, a HAR capture and a header search settle it directly, from what clients received rather than what the code promises.
Deciding When to Turn It On
EnableForHttps being opt-in doesn't mean it should stay off forever. Most services never come near the pattern BREACH exploits. The question worth asking for each response type you'd compress is simple: does this response ever mix a secret with something an attacker can influence?
Safe to compress over HTTPS, in general:
- Static or versioned assets (JS bundles, CSS, images) that don't vary by user or reflect request input.
- Read-only API responses built entirely from server-side or database state, with no user-supplied string echoed back into the body.
- Public JSON metadata, catalog listings, and similar content with no per-session secret embedded in it.
Worth a closer look before compressing:
- A response that reflects a query parameter, form field, or header value back into the body and also carries a session-bound secret (CSRF token, API key, personalized data) in that same response.
- Authenticated pages that echo search terms, error messages, or user input alongside anti-forgery tokens.
Closer to the first list, EnableForHttps = true is a straightforward, safe win. Closer to the second, either exclude that specific response type from compression (ResponseCompressionOptions supports exclusion by MIME type, or a custom provider), or leave the default off for those routes and handle them case by case.
The Fix, and a Test That Would Have Caught It
The fix is one line, plus a comment on why it was off in the first place. Without that context, a future reader (possibly you) will "helpfully" delete it as dead configuration:
builder.Services.AddResponseCompression(options =>
{
// Defaults to false to guard against BREACH-style compression oracle attacks on
// HTTPS responses that mix attacker-reflected input with a secret. Safe here:
// every response is static content or read-only data with no reflected input
// or embedded secrets.
options.EnableForHttps = true;
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
{
"application/json",
"text/javascript",
});
});
The fix that lasts is the test. Since WebApplicationFactory.CreateClient() defaults to http://localhost, exercising the code path production traffic hits means pointing the test client at an https:// base address instead. ResponseCompressionMiddleware only checks the request's scheme, so this works against the in-memory test host without any real TLS handshake:
var client = factory.CreateDefaultClient(new Uri("https://localhost"));
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
var response = await client.GetAsync("/api/data");
Assert.Contains("gzip", response.Content.Headers.ContentEncoding);
Run this against the code before the fix and it fails, reproducing exactly what the HAR capture showed. Add EnableForHttps = true and it passes. That's the gap between a test that exercises compression and a test that exercises compression on the protocol the service ships on.
A Checklist Before You Flip the Switch
- Confirm the symptom first. Capture a HAR of real HTTPS traffic and search it for
Content-Encoding. If it's absent across the board, this is very likely the cause. - Check your pipeline order. If
UseForwardedHeaders()runs beforeUseResponseCompression(), the app sees the client-facing scheme, andIsHttpswill betruebehind an HTTPS-terminating ingress even though the local socket is plain HTTP. - Classify each compressed response type. Static or versioned content and read-only data with no reflected input are safe. Anything that reflects request input alongside a session secret needs a closer look, or an exclusion.
- Set
EnableForHttps = trueexplicitly, with a comment recording why it was off and why it's safe now, so nobody "cleans it up" later while rereading the default. - Add an
https://-scheme integration test. It's the only kind that exercises this branch; a test client onhttp://localhostpasses whether or not the fix is in place. - Re-capture traffic after deploying to confirm
Content-Encodingshows up where expected, and only there.
Conclusion
A single boolean, defaulted for a real reason, can leave a whole category of optimization dead in production while every local signal, code review, unit tests, health checks, insists it's working. Compression is just this week's example. Whenever a feature's activation depends on scheme, environment, or anything else that differs between a test harness and real traffic, "the code looks right" and "it runs right in production" are two separate claims, and reading the diff only checks one of them. Capture what clients receive before trusting what the configuration promises.
