Configuring HTTP Proxy for gRPC in C# Without Environment Variables
Source: Dev.to

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.
Common approaches you may have seen:
- Set
http_proxyenvironment variable ✅ Works, but affects all HTTP traffic - Use
Grpc.Net.ClientwithHttpClientHandler.Proxy✅ Clean, but requires a library migration - Set
grpc.http_proxychannel option ❌ Doesn’t work inGrpc.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)
| 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 simply:
- Parses the proxy URL
- Sets internal channel arguments
- Uses those 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 a TCP tunnel:
┌────────┐ ┌───────┐ ┌────────────┐
│ 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, the TLS handshake and gRPC communication flow transparently through it.
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.
Effect: 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.
Problem without it: gRPC would send SNI for the proxy hostname and validate the proxy’s certificate—both wrong.
Effect: TLS ClientHello contains the correct SNI (api.example.com) and certificate validation is performed against api.example.com.
Format: hostname (no port).
3. DefaultAuthority
Purpose: Sets the HTTP/2 :authority pseudo‑header.
Problem without it: The :authority header would be the proxy address, breaking server routing.
Effect:
: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] │
└─────────────────────────────────────────────────────────────────┘