DEV Community

Cover image for Configuring HTTP Proxy for gRPC in C# Without Environment Variables
Akash Panchal
Akash Panchal

Posted on

Configuring HTTP Proxy for gRPC in C# Without Environment Variables

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_proxy environment variable ✅ Works, but affects ALL HTTP traffic
  • Use Grpc.Net.Client with HttpClientHandler.Proxy ✅ Clean, but requires library migration
  • Set grpc.http_proxy channel 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:

  1. Parses the proxy URL
  2. Sets internal channel arguments
  3. 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)      │
    │                  │                   │
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ✓                                 │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)