Tunneling mTLS Through a Cloud-to-On-Prem Connector Without Breaking Certificate Auth
A recurring problem in large, security-critical environments: an on-prem service only accepts connections that (a) come from an allow-listed internal IP range and (b) present a valid mutual TLS (mTLS) client certificate. That’s a solid perimeter — until a cloud-hosted application (in this case, on Cloud Foundry) needs to call it.
Why the obvious path breaks
The standard way to reach an on-prem service from a cloud platform is a cloud-to-on-prem connector — an agent that opens an outbound tunnel from inside the network and lets the cloud platform route requests through it. It solves the IP allow-list problem: traffic now originates from an internal address.
It does not solve the mTLS problem, because most connectors act as an HTTP(S) reverse proxy: they terminate the incoming TLS connection, inspect/forward the HTTP request, and open a new connection to the origin server. That second connection has no client certificate on it — the connector isn’t the app, so it can’t present the app’s identity.
The fix: tunnel, don’t terminate
A SOCKS5 proxy operates one layer lower than an HTTP reverse proxy — it forwards raw TCP bytes rather than parsing and re-issuing HTTP requests. So instead of asking the connector to speak HTTPS to the origin on the app’s behalf, the app opens its own TLS connection (with its own client certificate) and simply routes the encrypted bytes of that connection through the connector via SOCKS5.
The connector never sees plaintext, never touches the TLS handshake, and has no certificate to present — because it doesn’t need one. The mTLS handshake happens directly between the calling app and the on-prem origin, exactly as if the connector wasn’t in the path at all. It’s just carrying bytes.
Conceptually, a client making this call looks like a normal mTLS request, just routed through a local SOCKS5 endpoint:
import httpx
# The proxy tunnels raw bytes to the connector; TLS is still
# negotiated end-to-end between this client and the origin.
client = httpx.Client(
proxy="socks5://127.0.0.1:1080",
cert=("client.crt", "client.key"),
verify="onprem-ca-bundle.pem",
)
response = client.get("https://onprem-service.internal/api/resource")
Here’s what actually happens on the wire, step by step, once that request goes through the shared proxy rather than talking to the connector directly:
From a one-off fix to a shared platform capability
The tunnel solved the immediate problem, but it wasn’t the last app that needed on-prem access — it was one of many. Left alone, every team would rebuild the same SOCKS5 setup, and worse, every team would need direct access to sensitive client certificates.
Instead, we centralized it into a single internal proxy service:
- Certificate custody stays in one place. The proxy service is the only thing that pulls client certificates out of the internal credential store at request time. Consuming applications never handle raw private keys.
- One integration point for every team. Any app needing on-prem access points at the shared proxy instead of standing up its own tunnel, connector routing, and cert management.
- Access is scoped and auditable. Because every on-prem call flows through one service, granting, rotating, or revoking a team’s access — and logging who called what — happens in one place instead of N places.
A simplified shape of that proxy service — the part every consuming app is spared from writing itself:
// Shared proxy service: one endpoint per on-prem service, fronted internally
app.post("/proxy/onprem/:service", async (req, res) => {
// 1. Resolve which client certificate this caller is entitled to use
const { cert, key } = await credentialStore.getCertificate(req.headers["x-app-id"]);
// 2. Open a SOCKS5-tunneled, mTLS connection to the real on-prem origin
const agent = new SocksMtlsAgent({
socksProxy: "socks5://connector.internal:1080",
cert,
key,
ca: onPremCaBundle,
});
// 3. Forward the original request through that tunnel and stream the response back
const upstream = await fetch(`https://${req.params.service}.internal${req.path}`, {
method: req.method,
body: req.body,
agent,
});
res.status(upstream.status).send(await upstream.text());
});
| Without shared proxy | With shared proxy | |
|---|---|---|
| Client certificates | Copied into every consuming app | Held in one place only |
| Onboarding a new app | Rebuild tunnel + cert handling | Call one internal HTTP endpoint |
| Revoking access | Chase down every app that has a copy | Revoke in one service |
| Audit trail | Scattered across N apps’ logs | Centralized, one place to query |
The general lesson
When a network boundary “solves” your reachability problem but silently drops something your protocol depended on (here, the client certificate), look one layer down the stack before working around it. Reverse proxies terminate; the fix was to stop asking for a reverse proxy and route raw, encrypted bytes instead. And once you’ve solved a hard cross-cutting problem like this once, the higher-leverage move is turning it into a service the rest of the org can consume — not a pattern every team has to rediscover.