Chapter 02 · Cloudflare · Spring · Java · HTTP · SOAP
Published May 2026 · 9 min read
CF-RAY: -
curl from the pod worked fine. The application from the same pod did not. Same network. Same endpoint. Same payload. One got a Ray ID. One got a dash. The answer was hiding in the bytes - before they were encrypted.
A Java Spring application was making outbound SOAP calls to an external service. It had worked fine for months. Then the upstream team migrated their endpoint behind Cloudflare. The application URL was updated. And every single call started failing with a clean 400 Bad Request.
The response headers told an unusual story:
HTTP/2 400
server: cloudflare
content-type: text/html
cf-ray: -That dash where a Ray ID should be is Cloudflare's way of saying the request never entered its routing pipeline. No Ray ID means no Cloudflare log entry. No log entry means the origin never saw it. The request was being terminated at the very edge - during the connection phase, before HTTP processing began.
The same endpoint worked fine through a different firewall path. Same codebase. Same pod. Same credentials. Only Cloudflare refused it.
CF-RAY: - isn't an error response. It's a void. The request entered Cloudflare's edge and ceased to exist.
The first step was to establish whether this was a network problem or an application problem. From inside the application pod, a manual curl call to the same endpoint:
kubectl exec -it <app-pod> -n <namespace> -- \
curl -v -X POST "https://<endpoint>/ws/Service" \
-H "Authorization: Basic <base64-encoded-credentials>" \
-H "Content-Type: text/xml" \
-d '<test/>' 2>&1 | grep -E "HTTP|cf-ray|CF-Ray"
# Output:
# < HTTP/2 500
# < cf-ray: 9f7803e50a1d5f17-LHR <- real Ray ID, reached Cloudflarecurl got through. The origin returned a 500 - an authentication failure on the dummy payload - but the request reached Cloudflare, was routed, and logged. The network was clean. The problem was in the application itself.
Around this time, it surfaced that the application had recently had a change merged: the HTTP client factory in WebConfig.java had been switched from Spring's default SimpleClientHttpRequestFactoryto HttpComponentsClientHttpRequestFactory - Apache HttpClient under the hood. The change was well-intentioned: Apache HttpClient has better connection pooling and retry behaviour. But it also has different default behaviours that nobody accounted for.
The endpoint didn't change. The network didn't change. One line in WebConfig.java changed - and Cloudflare noticed.
To find exactly what the application was sending, we needed to see the raw HTTP request - before it was encrypted by TLS. No packet capture required. Java has a built-in mechanism for this.
Adding a single JVM argument to the deployment causes JSSE (Java's TLS implementation) to dump the plaintext of every TLS record to stderr -before encryption, in readable form:
kubectl patch deployment <app-deployment> -n <namespace> \
--type=json -p='[{
"op": "add",
"path": "/spec/template/spec/containers/0/args/0",
"value": "-Djavax.net.debug=ssl:record:plaintext"
}]'After the rollout, trigger one failing request, then extract the plaintext block from the pod logs:
kubectl logs <new-pod> -n <namespace> | \
grep -A 50 "Plaintext before ENCRYPTION"The output showed the exact HTTP headers being sent over the wire. And there it was - not one issue, but two, both introduced by the same config change:
POST /ws/Service HTTP/1.1
Host: <endpoint>
Authorization: Basic <base64-encoded-credentials>
Authorization: Bearer <jwt-token>
Content-Type: text/xml
Accept-Charset: big5, big5-hkscs, cesu-8, euc-jp, euc-kr, gb18030,
ibm-thai, ibm00858, ibm01140, ibm01141, ibm01142, ibm01143, ibm01144,
ibm01145, ibm01146, ibm01147, ibm01148, ibm01149, ibm437, ibm775,
ibm850, ibm852, ibm855, ibm857, ibm860, ibm861, ibm862, ibm863,
ibm864, ibm865, ibm866, ibm868, ibm869, ibm870, ibm871, iso-2022-cn,
iso-2022-jp, iso-2022-jp-2, iso-2022-kr, ... [50+ charsets]Two Authorization headers. Fifty charsets. Cloudflare saw the first one and rejected the request before reading the rest.
Two root causes, both introduced by switching to Apache HttpClient:
Duplicate Authorization header
Spring Security was propagating the inbound user's Bearer JWT token onto outbound service calls. With SimpleClientHttpRequestFactory, this behaviour was masked - it didn't forward interceptors. HttpComponentsClientHttpRequestFactory does. So the outbound SOAP call was carrying both the correct Basic Auth credential and the user's Bearer token. Cloudflare treats duplicate Authorization headers as a malformed HTTP request and rejects at the edge - RFC 7230 violation, no routing, no Ray ID.
Oversized Accept-Charset header
Apache HttpClient 4.5.8 automatically appends the full JVM charset registry as an Accept-Charset header. That's 50+ character sets - big5, cesu-8, euc-jp, ibm437, windows-1252, and dozens more. SimpleClientHttpRequestFactory never did this. The resulting header was several kilobytes long, exceeding Cloudflare's header size limits and contributing to the edge rejection.
To confirm, we replayed the exact headers via curl from the same pod. Duplicate Authorization plus the full Accept-Charset list. Immediate400 CF-RAY: - - identical to the application failure. Root cause confirmed.
curl -X POST "https://<endpoint>/ws/Service" \
-H "Authorization: Basic <base64-encoded-credentials>" \
-H "Authorization: Bearer <jwt-token>" \
-H "Accept-Charset: big5, big5-hkscs, cesu-8, euc-jp, euc-kr..." \
-H "Content-Type: text/xml" \
-d '<test/>'
# Result:
# HTTP/1.1 400 Bad Request
# CF-Ray: - <- exact same failure reproducedTwo fixes required from the application team, corresponding to the two root causes:
@Configuration
public class WebConfig {
@Bean
public RestTemplate cnsRestTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
// Create a RestTemplate that does NOT inherit Spring Security interceptors
// Prevents inbound Bearer token from being propagated to outbound calls
RestTemplate restTemplate = new RestTemplate(factory);
// Explicitly set only the interceptors this bean needs - none by default
restTemplate.setInterceptors(Collections.emptyList());
return restTemplate;
}
}// Add a request interceptor to remove the auto-injected Accept-Charset header
restTemplate.getInterceptors().add((request, body, execution) -> {
request.getHeaders().remove(HttpHeaders.ACCEPT_CHARSET);
return execution.execute(request, body);
});After both fixes were applied, requests to the Cloudflare-fronted endpoint started returning real Ray IDs. The origin was reached. Integrations recovered.
The diagnostic flag was removed immediately after the log capture - it logs all TLS plaintext including credentials and tokens, and must never be left running in a live environment:
kubectl patch deployment <app-deployment> -n <namespace> \
--type=json -p='[{
"op": "remove",
"path": "/spec/template/spec/containers/0/args/0"
}]'Cloudflare enforces HTTP spec strictly - your old firewall probably did not
The application had been sending duplicate Authorization headers and an oversized Accept-Charset for a long time. The previous firewall silently tolerated both. Cloudflare rejected them immediately. Migrating behind a CDN or WAF is not just a network change - it is an HTTP compliance audit. Issues that were invisible for months will surface the moment Cloudflare sees the traffic.
Spring Security interceptors propagate to all RestTemplate instances by default
If your RestTemplate bean is created in a Spring Security context without explicitly clearing interceptors, Spring Security will attach its SecurityContextRestTemplateCustomizer - which propagates the inbound authentication token to all outbound calls. For service-to-service calls that use their own credentials, this is always wrong.
-Djavax.net.debug=ssl:record:plaintext is the most powerful Java HTTP diagnostic tool you're not using
No mitmproxy. No certificate pinning bypass. No network tap. One JVM argument dumps every HTTP header and body before TLS encryption, directly into your pod logs. It works because JSSE owns the encryption layer and exposes the plaintext at the record level. Use it, capture what you need, remove it immediately.
When CF-RAY is a dash, replay via curl to confirm
curl from the same pod as the application is the fastest way to isolate whether the problem is network or application. If curl gets a real Ray ID and your app gets a dash, the network is clear and the problem is in your HTTP client. Reproduce the failure by replaying the exact headers curl receives from your wire log - that confirms root cause definitively.
1. curl from the same pod
-> gets real Ray ID? Problem is in the app, not the network.
-> also gets CF-RAY: -? Problem is network/IP/egress level.
2. Add -Djavax.net.debug=ssl:record:plaintext to JVM args
-> grep logs for "Plaintext before ENCRYPTION"
-> read every header the app is actually sending
3. Look for:
-> Duplicate headers (especially Authorization)
-> Oversized headers (Accept-Charset, Cookie, custom headers)
-> Content-Length + Transfer-Encoding: chunked conflict
4. Replay the exact headers via curl to confirm
-> same headers = same CF-RAY: - = root cause confirmed
5. Remove the diagnostic flag immediately after capture
-> it logs credentials in plaintext - treat with careWritten by
Vishnu KS
Senior Cloud Infrastructure Engineer