The Challenge
You have a gRPC client in C# using Grpc.Core that needs to route traffic through an HTTP proxy. Sounds simple, right?
Not quite.
If you've searched for solutions, you've probably found:
- Set
http_proxyenvironment variable ✅ Works, but affects ALL HTTP traffic - Use
Grpc.Net.ClientwithHttpClientHandler.Proxy✅ Clean, but requires library migration - Set
grpc.http_proxychannel option ❌ Doesn't work in Grpc.Core
I needed per-channel proxy configuration without affecting other traffic and without migrating libraries. So I dove into the gRPC C-core source code to understand how http_proxy actually works.
Source Code References (gRPC v1.10.0)
If you want to explore the internals yourself, here are the key files:
| File | Purpose |
|---|---|
http_proxy.cc |
Reads http_proxy env var, sets channel args |
http_connect_handshaker.cc |
Sends HTTP CONNECT request to proxy |
secure_channel_create.cc |
SSL/TLS target name override handling |
channel.cc |
Default authority header logic |
ChannelOptions.cs |
C# channel option constants |
What I Discovered
When gRPC honors the http_proxy environment variable, it doesn't do anything magical. It simply:
- Parses the proxy URL
- Sets internal channel arguments
- Uses these arguments during connection
The key insight: these channel arguments are accessible via ChannelOption in C#!
The Solution
Understanding HTTP CONNECT Tunneling
HTTP proxies use the CONNECT method to create TCP tunnels:
┌────────┐ ┌───────┐ ┌────────────┐
│ Client │ TCP │ Proxy │ TCP │ gRPC Server│
└───┬────┘ └───┬───┘ └─────┬──────┘
│ │ │
│ TCP Connect │ │
│─────────────────►│ │
│ │ │
│ CONNECT host:port HTTP/1.1 │
│─────────────────►│ │
│ │ │
│ │ TCP Connect │
│ │──────────────────►│
│ │ │
│ HTTP/1.1 200 Connection established │
│◄─────────────────│ │
│ │ │
│◄──────────── TCP Tunnel ───────────►│
│ (TLS + gRPC flows here) │
│ │ │
Once the tunnel is established, TLS handshake and gRPC communication flow through transparently.
The Three Magic Channel Options
private Channel CreateChannel(string targetEndpoint, SslCredentials credentials, string proxyEndpoint)
{
string proxy = proxyEndpoint?.Trim();
if (!string.IsNullOrEmpty(proxy))
{
// Extract hostname without port for SSL validation
string targetHost = targetEndpoint.Contains(":")
? targetEndpoint.Substring(0, targetEndpoint.LastIndexOf(':'))
: targetEndpoint;
var options = new[]
{
// 1. HTTP CONNECT tunnel target (host:port)
new ChannelOption("grpc.http_connect_server", targetEndpoint),
// 2. SSL SNI + certificate validation (hostname only)
new ChannelOption(ChannelOptions.SslTargetNameOverride, targetHost),
// 3. HTTP/2 :authority header (host:port)
new ChannelOption(ChannelOptions.DefaultAuthority, targetEndpoint)
};
// Channel connects to PROXY, tunnels to TARGET
return new Channel(proxy, credentials, options);
}
// Direct connection
return new Channel(targetEndpoint, credentials);
}
What Each Option Does
1. grpc.http_connect_server
Purpose: Tells gRPC where to tunnel
What happens: When gRPC connects to the channel target (the proxy), it sends:
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443
Format: host:port (port is required!)
2. SslTargetNameOverride
Purpose: SSL/TLS hostname for SNI and certificate validation
The problem: Without this, gRPC would:
- Send SNI for the proxy hostname
- Validate the certificate against the proxy hostname
- Both are WRONG — we need the actual target's certificate!
What happens:
- TLS ClientHello contains correct SNI:
api.example.com - Certificate validation checks against:
api.example.com
Format: hostname (NO port — SSL certificates don't include ports)
3. DefaultAuthority
Purpose: Sets the HTTP/2 :authority pseudo-header
The problem: gRPC servers use :authority for routing. Without this override, it would be set to the proxy address.
What happens:
:method: POST
:scheme: https
:authority: api.example.com:443 ← Correct!
:path: /mypackage.MyService/MyMethod
Format: host:port (port often required for server routing)
Complete Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ YOUR APPLICATION │
│ Channel target: proxy-server:8080 │
│ Options: │
│ grpc.http_connect_server = "api.example.com:443" │
│ SslTargetNameOverride = "api.example.com" │
│ DefaultAuthority = "api.example.com:443" │
└──────────────────────────────┬──────────────────────────────────┘
│
Step 1: TCP Connect │
▼
┌─────────────────────────────────────────────────────────────────┐
│ HTTP PROXY │
│ Receives: CONNECT api.example.com:443 HTTP/1.1 │
│ Action: Opens TCP connection to api.example.com:443 │
│ Returns: HTTP/1.1 200 Connection established │
└──────────────────────────────┬──────────────────────────────────┘
│
Step 2: TCP Tunnel │ (transparent passthrough)
▼
┌─────────────────────────────────────────────────────────────────┐
│ TLS HANDSHAKE │
│ ClientHello SNI: "api.example.com" (SslTargetNameOverride) │
│ Server sends certificate for: "api.example.com" │
│ Client validates cert against: "api.example.com" ✓ │
│ [mTLS: Client certificate sent if configured] │
└──────────────────────────────┬──────────────────────────────────┘
│
Step 3: Encrypted │
▼
┌─────────────────────────────────────────────────────────────────┐
│ gRPC SERVER │
│ Receives HTTP/2 request: │
│ :authority = "api.example.com:443" (DefaultAuthority) │
│ :path = /mypackage.MyService/MyMethod │
│ Routes and processes request ✓ │
└─────────────────────────────────────────────────────────────────┘
Why Not Just Use Environment Variables?
| Approach | Scope | Risk |
|---|---|---|
http_proxy env var |
Global (all HTTP) | May break other services |
| Channel Options | Per-channel | Isolated, controlled |
Environment variables are global. If your application makes HTTP calls to multiple services, and only ONE needs proxying, you can't use environment variables safely.
Channel options give you surgical precision.
By understanding how gRPC handles proxies internally, we can configure per-channel proxy support without environment variables, keeping our other HTTP traffic unaffected.
Have you faced similar challenges with gRPC proxying? Drop a comment below!
Top comments (0)