Avik
back|networking

How a Man-in-the-Middle Proxy Actually Works: Building Network Interception from Scratch

By Avik Mukherjee  |  Apr 5, 2026 · 24 min read · Updated Apr 5, 2026

I wanted to understand how debugging proxies like Charles and Fiddler can see your HTTPS traffic. Everyone says "they intercept TLS," but that doesn't explain how.

The answer involves broken trust, dynamic certificates, and convincing your browser that a proxy is actually Amazon.

So I built one. From scratch. In Go. Over a weekend. No magic libraries—just sockets, certificates, and the HTTP specification.

What you're reading is my implementation of a MITM proxy learned by doing, with explanations of why each piece exists. I kept it simple enough to understand in a few hours, but real enough that it actually works on live websites.

The full source is on GitHub: Avik-creator/mitm-proxy.

What Even Is a MITM Proxy?#

First, the mental model.

Normally, HTTPS is designed to prevent exactly what a MITM proxy does. Your browser:

  1. Connects to example.com
  2. Performs a TLS handshake (the cryptographic dance that establishes a secure key)
  3. Validates that the certificate is signed by a trusted Certificate Authority (checking the chain of trust)
  4. If the cert is valid, uses the agreed-upon encryption key so only example.com can read subsequent data
  5. Encrypts all traffic with this key, making it unreadable to anyone in between

A MITM proxy breaks this by playing both sides simultaneously:

  • To your browser, it pretends to be example.com (with a forged certificate signed by a CA the browser trusts)
  • To example.com, it pretends to be your browser (with a normal, legitimate connection)

Here's the traffic flow:

  1. Your browser connects to the proxy (thinking it's connecting to Amazon)
  2. The proxy intercepts at the TLS layer and presents a fake amazon.com certificate
  3. Your browser validates it ("Is this signed by a trusted CA?") and says yes
  4. Browser and proxy now have an encrypted tunnel (but using the fake cert)
  5. Browser sends encrypted data through this tunnel
  6. Proxy decrypts it (because it has the private key for the fake cert)
  7. Proxy sees: GET /products HTTP/1.1, Cookie: session=abc123, ...
  8. Proxy optionally modifies the request
  9. Proxy re-encrypts it but now connects to the real Amazon with a legitimate TLS handshake
  10. Real Amazon responds (encrypted)
  11. Proxy decrypts the response, optionally modifies it
  12. Proxy re-encrypts it with the fake certificate
  13. Sends it back through the tunnel to the browser
  14. Browser decrypts it and renders the page

The browser has no idea it wasn't talking to Amazon. As far as it knows, the TLS certificate was valid, the connection was secure, and everything is fine. Amazon has no idea it wasn't talking to the browser—it just sees a normal client connection.

The key insight: The proxy isn't breaking cryptography at all. Encryption still works perfectly. It's breaking certificate trust. It generates a fake example.com certificate signed by a Certificate Authority that your OS/browser trusts because you explicitly installed it.

This is not a weakness in TLS. This is the intended security model: "Your computer is trustworthy." Once you install a root certificate, you're saying "I trust this entity to not subvert me." The proxy is then perfectly legitimate from HTTPS's perspective.

That's the whole trick. Everything else is plumbing.

The Four Parts#

Your proxy needs:

  1. A Certificate Authority (CA) — A root cert and key that can sign fake certificates for any domain
  2. A Socket Hijacker — The ability to intercept the HTTPS handshake before the browser's TLS code runs
  3. A Middleware Chain — Hooks to modify requests and responses before they're forwarded
  4. Transport Logic — Actually forwarding traffic to the real upstream server

Let's build each one.


Part 1: The Certificate Authority#

The CA is the trust anchor. When you run this proxy, it generates a root certificate. You install this cert in your OS/browser as "trusted." Now any certificate signed by this CA will be accepted without warnings.

Generating the Root CA#

go
func generateAndSave(certFile, keyFile string) (*CA, error) {
    // Generate an ECDSA key pair
    key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    if err != nil {
        return nil, err
    }

What's happening:

ecdsa.GenerateKey() creates a private/public key pair using elliptic curve cryptography. elliptic.P256 is the curve—a well-tested mathematical function that makes cryptography work. For HTTPS, P256 is standard and fast.

You need this key to sign certificates later. It's your root of trust.

go
    serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))

Every certificate needs a unique serial number. This generates a 128-bit random number—big enough that collisions are impossible.

go
    tmpl := &x509.Certificate{
        SerialNumber:          serial,
        Subject:               pkix.Name{CommonName: "MITM Proxy CA", Organization: []string{"MITM Proxy"}},
        NotBefore:             time.Now().Add(-time.Hour),
        NotAfter:              time.Now().Add(10 * 365 * 24 * time.Hour),
        KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
        BasicConstraintsValid: true,
        IsCA:                  true,
    }

This template defines what the certificate says about itself:

  • SerialNumber — Unique identifier
  • Subject/CommonName — "MITM Proxy CA" (shown in cert details)
  • NotBefore/NotAfter — Valid for 10 years (from 1 hour ago to 10 years from now; the grace period handles clock skew)
  • KeyUsage — Marks this cert as able to sign other certificates (that's the CertSign flag)
  • IsCA: true — Tells browsers "this is a Certificate Authority, not a website"
go
    certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)

This is the magic line. CreateCertificate() does:

  1. Takes the template
  2. Signs it with key (private key)
  3. Produces DER-encoded binary data

Notice the template appears twice — that's self-signing. The CA cert is signed by itself, meaning "I am the authority on my own validity."

go
    if err := writePEM(certFile, "CERTIFICATE", certDER); err != nil {
        return nil, err
    }

Now we save the cert as PEM (text format) to disk. PEM is just base64-encoded DER wrapped in text markers:

code
-----BEGIN CERTIFICATE-----
MIICljCCAX4CCQC7W4GZ+MJ...
-----END CERTIFICATE-----

Your browser will read this file and add it to its "trusted root CAs."

Generating Per-Host Certificates#

Once you have the root CA, you can forge certificates for any domain. Here's the trick:

go
func (ca *CA) TLSConfigForHost(host string) (*tls.Config, error) {
    cert, err := ca.leafCert(host)
    if err != nil {
        return nil, err
    }
    return &tls.Config{
        Certificates: []tls.Certificate{*cert},
        NextProtos:   []string{"h2", "http/1.1"},
    }, nil
}

For each host (e.g., amazon.com), the proxy calls TLSConfigForHost(). This generates a new certificate—signed by the root CA—that claims to be amazon.com.

The certificate isn't trusted because it's real. It's trusted because its issuer (the root CA) is in your trust store.

Your browser doesn't validate that amazon.com actually issued this cert. It just validates that someone you trust signed it. And the proxy is "someone you trust" because you installed the root CA.

Certificate trust chain
Root (MITM Proxy CA)
Subject:
CN=MITM Proxy CA
Issuer:
CN=MITM Proxy CA (self-signed)
signs
Leaf (forged origin)
Subject:
CN=example.com
Issuer:
CN=MITM Proxy CA
Browser validation

Chain verifies: a trusted root signed this leaf. TLS cryptography is normal; you have delegated trust to whoever holds the root key.

SPKI and “public key” pinning#

SPKI (Subject Public Key Info) is the part of an X.509 certificate that carries the public key: algorithm plus key bits. It does not include the name, validity dates, or issuer—only which key this certificate binds to the name.

Many apps that pin TLS go one level deeper than “any CA I trust.” They expect a specific public key (or a hash of the SPKI) that belongs to the real service. Your MITM leaf for example.com can have a perfectly valid chain under your installed root, but its SPKI still differs from the real site’s key—so the app aborts even when a desktop browser would show a lock icon. That is what the “App pins public key / SPKI” toggle in the diagram above is illustrating.

Pinning can target the whole certificate fingerprint or the SPKI hash; SPKI-style pins survive some certificate renewals (same key, new cert) but break when the operator rotates keys—on purpose, so impersonation with a different key fails.


Part 2: Intercepting the HTTPS Handshake#

Now for the plumbing. Your browser tries to connect normally. The proxy needs to intercept that CONNECT request and hijack the connection.

The CONNECT Protocol#

Your browser sends:

code
CONNECT amazon.com:443 HTTP/1.1
Host: amazon.com:443
Connection: keep-alive
Proxy-Connection: keep-alive

This is an HTTP request, but it's special. It's not asking for a resource. It's asking for a tunnel.

"I (the browser) want to establish a tunnel through you (the proxy) to amazon.com on port 443. After I establish this tunnel, I'm going to switch protocols from HTTP to TLS, so please just get out of the way and let me talk directly to that computer."

The browser does this because:

  1. It can't do TLS directly through the proxy (TLS connection is point-to-point)
  2. It needs the proxy to establish TCP connectivity to amazon.com:443
  3. Once TCP is connected, the browser will handle TLS itself

The proxy should respond:

code
HTTP/1.1 200 Connection Established


Note the blank line at the end—that signals end of HTTP headers. After this point, the connection is no longer HTTP. It's just raw bytes.

Now—this is the key part—both sides should switch from HTTP to TLS. The browser will initiate a TLS handshake. The proxy needs to intercept this moment.

Normally:

  • Browser would start TLS handshake
  • Real amazon.com responds with its real certificate
  • Browser validates amazon.com's real certificate
  • Connection established

With a MITM proxy intercepting:

  • Browser starts TLS handshake
  • Proxy intercepts the connection at the TCP layer
  • Proxy wraps the connection in TLS (acting as the TLS server)
  • Proxy presents a fake amazon.com certificate (signed by the CA in the browser's trust store)
  • Browser validates it and thinks it's talking to Amazon
  • Connection established (but with forged certificate)

The proxy has now successfully intercepted the TLS layer.

CONNECT → tunnel → TLS (browser side)

The proxy answers CONNECT in HTTP, then the same TCP socket carries non-HTTP bytes. Your MITM layer becomes the TLS server toward the browser.

1 · CONNECT (HTTP to proxy)
Browser

CONNECT example.com:443 HTTP/1.1

Proxy

HTTP handler parses request line + Host

On the wire (browser ↔ proxy)

Cleartext HTTP/1.1 on TCP to proxy :8080

The Handler#

go
func (p *Proxy) handleCONNECT(w http.ResponseWriter, r *http.Request) {
    host, _, err := net.SplitHostPort(r.Host)
    if err != nil {
        host = r.Host
    }

Extract just the hostname from amazon.com:443.

SplitHostPort() does exactly what it sounds like:

  • Input: amazon.com:443
  • Output: host = amazon.com, port = 443

If there's no colon (malformed), the err is non-nil, so we just use the whole thing as the hostname.

We only need the hostname because:

  1. We'll use it to generate a certificate for that specific domain
  2. Port 443 is standard for HTTPS; we don't need to vary behavior based on it
  3. Different ports to the same host should use the same certificate
go
    if !shouldInterceptHost(host) {
        // For these hosts, establish standard TLS without our CA interception
        upstreamConn, err := tls.Dial("tcp", r.Host, &tls.Config{
            InsecureSkipVerify: p.cfg.InsecureUpstream,
        })

Some sites don't work if you intercept them. Why? Several reasons:

  1. Certificate / public-key pinning — Many mobile apps and some browsers (or extensions) expect a specific certificate or SPKI (hash of the server’s public key), not merely “any trusted CA.” A forged leaf signed by your debug CA still has the wrong public key, so the pin fails and the connection is dropped—see SPKI and “public key” pinning above.

  2. ALPN negotiation — Some CDNs negotiate protocol version (HTTP/1.1 vs HTTP/2) during the TLS handshake. If they get an unexpected certificate, the negotiation fails.

  3. Streaming protocols — Video infrastructure often uses custom protocols over TLS. If you intercept and only understand HTTP, you lose the ability to forward the custom protocol.

  4. Certificate transparency checks — Some implementations check that the certificate appears in public CT logs. A dynamically generated cert won't be there.

For these hosts, the proxy does a "transparent passthrough"—it just forwards the encrypted tunnel without decrypting.

How? It connects to the real server with tls.Dial() and then copies data bidirectionally between the browser and the real server. The encryption happens end-to-end; the proxy never sees plaintext.

This is the honest thing to do. If a service explicitly breaks with MITM proxies, there's a reason—respect that.

go
    hj, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "hijacking not supported", http.StatusInternalServerError)
        return
    }
    clientConn, _, err := hj.Hijack()

http.Hijacker is Go's interface for "give me the raw socket." This is critical to understand.

Normally, when you write an HTTP handler in Go:

go
func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello"))
}

Go's HTTP server manages everything:

  • Parsing incoming data as HTTP
  • Calling your handler
  • Writing response headers and body
  • Closing the connection when done

But a MITM proxy needs to stop being an HTTP server. After sending the "200 Connection Established" response, it needs to hand control of the raw TCP socket to the proxy logic.

http.Hijacker lets you do this mid-request. You say: "I'm taking over the socket. From now on, GO doesn't manage it—I do."

The ResponseWriter can optionally implement this interface. If it does, you can call Hijack() to get the raw socket.

Hijack() returns:

  • clientConn — A raw TCP connection to the browser. You can now read/write bytes directly without HTTP parsing.
  • bufReader — A buffered reader that might have already read some bytes from the socket (the HTTP parser may have peeked ahead)
  • bufWriter — A buffered writer attached to the connection

After hijacking:

  • The HTTP protocol layer is completely gone
  • You have a bare socket
  • You can switch protocols (HTTP → TLS)
  • Go won't close the connection automatically; you're responsible

This is the linchpin of how the proxy works. Without hijacking, you can't intercept at the right layer.

go
    _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))

Send the HTTP response to the browser over the raw socket (not using the HTTP response writer, which we've hijacked away).

This response means: "Your tunnel is ready. Switch to TLS."

Note the format:

code
HTTP/1.1 200 Connection Established\r\n    <- HTTP status line
\r\n                                    <- blank line (end of headers)

That blank line is critical. It signals the end of HTTP headers and the beginning of... nothing (from HTTP's perspective). Both sides now expect raw data.

From this point forward:

  • Browser will send TLS bytes
  • Proxy will receive TLS bytes
  • Neither side is using HTTP anymore

Now the browser thinks it has a tunnel to Amazon and will start the TLS handshake.

go
    tlsCfg, err := p.cfg.CA.TLSConfigForHost(host)
    if err != nil {
        logger.LogError("TLS config for %s: %v", host, err)
        return
    }
    tlsCfg.NextProtos = []string{"http/1.1"}
 
    tlsClient := tls.Server(clientConn, tlsCfg)
    if err := tlsClient.Handshake(); err != nil {
        logger.LogError("TLS handshake with client (%s): %v", host, err)
        return
    }
    defer tlsClient.Close()

Here's where the MITM happens. This is the critical moment.

tlsCfg := p.cfg.CA.TLSConfigForHost(host) generates (or retrieves from cache) a TLS configuration for the specific host. This includes:

  • A certificate claiming to be amazon.com
  • Signed by the root CA (that we generated and the browser trusts)
  • The private key matching that certificate

tls.Server() wraps the raw socket and says: "I'm a TLS server. I will perform the TLS handshake using this certificate."

Think about what happens next:

  1. Browser (on the other end of clientConn) sends a TLS ClientHello
  2. Proxy responds with ServerHello + the fake amazon.com certificate
  3. Browser inspects the certificate, finds issuer MITM Proxy CA in trust store, validates signature
  4. Browser sends the final handshake messages (key exchange, finished)
  5. TLS handshake complete

The handshake is not broken. From the browser's perspective, everything is correct. The certificate is validly signed by a trusted authority. The handshake is cryptographically sound. The browser has no reason to be suspicious.

Handshake complete. The browser and proxy now have an encrypted tunnel. But here's the key: this tunnel is encrypted under a certificate the browser trusts. The proxy has the private key for this certificate, so it can decrypt everything.

The proxy can now read and write decrypted data to tlsClient. Each Read() decrypts data from the browser. Each Write() encrypts data to the browser.

Meanwhile, the real Amazon never sees any of this. The browser hasn't even tried to connect to Amazon yet.

Two TLS sessions (the real MITM shape)

The browser never shares a session key with the origin. Session A terminates at the proxy; session B is a new handshake to the real server. Plaintext exists only inside the proxy process.

Browser
TLS session A
Proxy
TLS session B
Origin
Plaintext — After decrypting session A, the proxy sees HTTP. It may run middleware, then opens session B and sends the same request encrypted with a different key material.

Reading Decrypted Traffic#

go
    br := bufio.NewReader(tlsClient)
    hdr, _ := br.Peek(3)
 
    // HTTP/2 preface starts with "PRI"
    if string(hdr) == "PRI" && shouldInterceptHost(host) {
        p.handleH2Tunnel(tlsClient, br, host)
        return
    }
 
    for {
        req, err := http.ReadRequest(br)
        if err != nil {
            return
        }

Now comes the magic. The TLS layer is decrypted. tlsClient acts like a normal net.Conn, but whenever you read from it, it decrypts. Whenever you write to it, it encrypts.

Wrap it in a bufio.Reader for efficiency. Buffered I/O means we read in chunks rather than byte-by-byte.

br.Peek(3) looks at the first 3 bytes without consuming them. This lets us detect the protocol:

  • HTTP/1.1 starts with GET , POST, PUT , etc. (alphabetic)
  • HTTP/2 starts with PRI (the first 3 bytes of HTTP/2's binary preface)

If it's HTTP/2, handle it differently (with ReverseProxy for multiplexing complexity). Otherwise, handle as HTTP/1.1.

For HTTP/1.1:

go
req, err := http.ReadRequest(br)

http.ReadRequest() is Go's HTTP parser. It reads from br and parses the request line, headers, and body.

Now the proxy has access to plaintext HTTP. It can see headers and cookies, modify them, block, forward, and loop on keep-alive.

First bytes after TLS (br.Peek(3))

After the client handshake, the proxy peeks without consuming. PRI matches the HTTP/2 connection preface; otherwise the code path treats the stream as HTTP/1.1 and uses http.ReadRequest.

Peek(3) returns
"GET"→ branch
GET / HTTP/1.1
Host: example.com
…

Handler loop: ReadRequest, roundTrip, write response — textual and sequential per connection.


Part 3: The Middleware Chain#

Before forwarding upstream, the proxy can hook into the request/response cycle. This is where you'd implement request logging, ad blocking, header injection, etc.

The Chain Pattern#

go
type Chain struct {
    reqHooks  []RequestHook
    respHooks []ResponseHook
}
 
func (c *Chain) UseRequest(h RequestHook) *Chain {
    c.reqHooks = append(c.reqHooks, h)
    return c
}
 
func (c *Chain) RunRequest(req *http.Request) error {
    for _, h := range c.reqHooks {
        if err := h(req); err != nil {
            return err
        }
    }
    return nil
}

This is a classic middleware pattern:

  • Each hook is a function that can modify the request
  • Hooks run in order
  • If any hook returns an error, the chain stops and returns an error
  • The proxy can then respond with 403 Forbidden

Built-in Hooks#

go
func AddRequestHeader(key, value string) RequestHook {
    return func(req *http.Request) error {
        req.Header.Set(key, value)
        return nil
    }
}
 
func BlockHost(host string) RequestHook {
    return func(req *http.Request) error {
        if req.URL.Hostname() == host || req.Host == host {
            return &BlockedError{Host: host}
        }
        return nil
    }
}

AddRequestHeader returns a function that adds a header to every request. BlockHost returns a function that rejects requests to a specific domain.

In main.go, you'd use them like:

go
chain := middleware.New().
    UseRequest(middleware.AddRequestHeader("X-MITM-Proxy", "1")).
    UseResponse(middleware.AddResponseHeader("X-Intercepted-By", "mitm-proxy/1.0"))

Each request passes through: add "X-MITM-Proxy" header → forward upstream.

Middleware chain

TLS record layer decrypts; http.ReadRequest builds *http.Request (HTTP/1 path).

At the proxy (plaintext)
GET /checkout HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 …
Accept: text/html
Request hooks run before RoundTrip; response hooks after the response is received. Any hook error can abort the pipeline (e.g. blocked host → synthetic 403).

Part 4: The Forward#

Once the request is prepared, the proxy sends it upstream:

go
func (p *Proxy) roundTrip(req *http.Request) (*http.Response, bool, error) {
    // Snapshot original body for logging
    var reqBody []byte
    var err error
    reqBody, req.Body, err = logger.DrainBody(req.Body)
    if err != nil {
        return nil, false, err
    }

To log the request, you need to read the body. But reading consumes the stream—you can't read twice. So:

  1. Read the body once and save it
  2. Replace the request's Body with a new reader over the saved bytes
  3. Forward
go
    origHeaderSig := headerSignature(req.Header)
 
    // Run request middleware
    if err := p.cfg.Middleware.RunRequest(req); err != nil {
        if be, ok := err.(interface{ IsBlocked() bool }); ok && be.IsBlocked() {
            return blockedResponse(req), false, nil
        }
        return nil, false, err
    }
    reqModified := headerSignature(req.Header) != origHeaderSig

Check if middleware modified headers. This determines if we log the request as "modified."

go
    transport := p.transport()
    resp, err := transport.RoundTrip(req)

RoundTrip is Go's abstraction for "send a request, get a response." This actually connects to amazon.com and sends the (possibly modified) request.

go
    var respBody []byte
    respBody, resp.Body, err = logger.DrainBody(resp.Body)
 
    // Run response middleware
    if err := p.cfg.Middleware.RunResponse(req, resp); err != nil {
        return nil, false, err
    }
    // ...
 
    resp.Body = io.NopCloser(bytes.NewReader(respBody))
 
    return resp, reqModified || respModified, nil
}

Same pattern: drain response, run middleware, restore body.

The Transport Configuration#

go
func (p *Proxy) transport() *http.Transport {
    return &http.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: p.cfg.InsecureUpstream,
        },
        ForceAttemptHTTP2:     false,
        MaxIdleConns:          200,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
        DisableKeepAlives:     false,
        DisableCompression:    false,
    }
}

TLSClientConfig / InsecureSkipVerify — When the proxy connects upstream, skipping verification is useful for lab setups or nested proxies, but it means the proxy itself can be fooled by a fake origin.

ForceAttemptHTTP2: false — Keeps upstream HTTP/1.1 for simpler forwarding while the browser may still speak HTTP/2 to the proxy (translated via the H2 path).

PoolingMaxIdleConns, IdleConnTimeout, and keep-alive trade memory for fewer handshakes. A naive proxy that opens a new TCP+TLS for every request will feel slow fast.


Part 5: HTTP/2 Handling#

HTTP/2 is binary and multiplexed; the proxy detects it by peeking PRI (HTTP/2 connection preface).

Instead of parsing frames by hand, the proxy delegates to httputil.ReverseProxy.

go
func (p *Proxy) handleH2Tunnel(conn net.Conn, br *bufio.Reader, host string) {
    rp := &httputil.ReverseProxy{
        Director: func(req *http.Request) {
            req.URL.Scheme = "https"
            req.URL.Host = host
            removeHopByHop(req.Header)
        },
        Transport: p.transport(),
        ModifyResponse: func(resp *http.Response) error {
            return p.cfg.Middleware.RunResponse(resp.Request, resp)
        },

Director sets scheme/host and strips hop-by-hop headers. ModifyResponse runs your response middleware.

To serve HTTP/2 on a single hijacked connection, the code uses a net.Listener implementation that returns that one connection once—so http.Server can run the ALPN/H2 stack the way the standard library expects—plus a prefixedConn that replays bytes already peeked from the buffer (the PRI preface) so the parser sees a valid preface.

singleConnListener is a small adapter: first Accept() hands back the hijacked TLS connection; later calls block or error so the server treats it as a one-shot listener.

go
type singleConnListener struct {
    conn   net.Conn
    once   bool
    closed chan struct{}
}
 
func (l *singleConnListener) Accept() (net.Conn, error) {
    if l.once {
        <-l.closed
        return nil, fmt.Errorf("done")
    }
    l.once = true
    return l.conn, nil
}

prefixedConn wraps the real connection but reads first from a replay reader (peeked PRI… bytes), then continues on the underlying socket—otherwise the HTTP/2 preface would be missing after Peek.

go
type prefixedConn struct {
    net.Conn
    r io.Reader
}
 
func (c *prefixedConn) Read(b []byte) (int, error) {
    return c.r.Read(b)
}

The http.Server is wired with the same per-host TLSConfig from your CA; Serve drives the HTTP/2 session on that single accepted conn.


Part 6: WebSocket Support#

WebSockets start as HTTP, then become something else. The upgrade request looks like:

http
GET /chat HTTP/1.1
Host: server.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

After 101 Switching Protocols, frames are opaque to HTTP. A minimal approach: dial upstream TLS, write the same upgrade request, then shuttle bytes.

go
func (p *Proxy) handleWebSocket(clientConn net.Conn, req *http.Request) {
    upstreamConn, err := tls.Dial("tcp", req.URL.Host, &tls.Config{
        InsecureSkipVerify: p.cfg.InsecureUpstream,
    })
    if err != nil {
        logger.LogError("WS dial upstream: %v", err)
        return
    }
    defer upstreamConn.Close()
 
    if err := req.Write(upstreamConn); err != nil {
        logger.LogError("WS write upgrade: %v", err)
        return
    }
 
    done := make(chan struct{}, 2)
    go func() { io.Copy(upstreamConn, clientConn); done <- struct{}{} }()
    go func() { io.Copy(clientConn, upstreamConn); done <- struct{}{} }()
    <-done
}

Middleware still applies to the HTTP upgrade and the 101 response path; after upgrade, you are copying WebSocket frames unless you add frame-aware middleware.


Part 7: Putting It Together (main.go)#

go
func main() {
    addr := flag.String("addr", "127.0.0.1:8080", "Proxy listen address")
    certFile := flag.String("cert", "certs/ca.crt", "Path to CA certificate (created if missing)")
    keyFile := flag.String("key", "certs/ca.key", "Path to CA private key (created if missing)")
    verbosity := flag.Int("v", 1, "Verbosity: 0=silent, 1=info, 2=verbose")
    insecure := flag.Bool("insecure", false, "Skip TLS verification for upstream connections")
    flag.Parse()
 
    logger.SetLevel(logger.Level(*verbosity))
 
    if err := os.MkdirAll("certs", 0700); err != nil {
        fatal("create certs dir: %v", err)
    }
    rootCA, err := ca.New(*certFile, *keyFile)
    if err != nil {
        fatal("init CA: %v", err)
    }
 
    chain := middleware.New().
        UseRequest(middleware.AddRequestHeader("X-MITM-Proxy", "1")).
        UseResponse(middleware.AddResponseHeader("X-Intercepted-By", "mitm-proxy/1.0"))
 
    p := proxy.New(proxy.Config{
        Addr:             *addr,
        CA:               rootCA,
        Middleware:       chain,
        InsecureUpstream: *insecure,
    })
 
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 
    go func() {
        <-sigCh
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        p.Shutdown(ctx)
        os.Exit(0)
    }()
 
    if err := p.Start(); err != nil {
        fatal("proxy start: %v", err)
    }
}

Parse flags, load CA, wire middleware, handle signals, start listening.


The Trust Boundary#

Here's what I didn't understand until I built this:

HTTPS security is rooted in trust, not in "unbreakable magic."

Your browser trusts a set of root CAs. Installing one root gives that issuer the power to mint trusted names for any domain—until you remove it.

Corporate TLS inspection, AV HTTPS scanning, and malware that adds a root all sit on this same mechanism. HTTPS still protects you on the wire from third parties when you haven't opted into a new trust anchor.


What I Actually Learned#

http.Hijacker is the hinge. Without taking the socket back from the server stack, you cannot speak TLS to the client on that same TCP connection after CONNECT.

Logging is harder than interception. Draining bodies, restoring streams, compression, and streaming responses are where bugs show up.

HTTP/2 is worth delegating to ReverseProxy and the http.Server H2 path for a learning project; hand-rolling frames is a different project.

WebSockets after 101 are just bytes—bidirectional copy is correct for a minimal proxy.


What's Missing vs Real Proxies#

Charles and Fiddler add capture UI, breakpoints, throttling, scripting, automatic trust setup, and often SOCKS. This repo is intentionally small: standard library Go, clear control flow, enough to see plaintext HTTP after TLS.


Interactive: step through the tunnel#

End-to-end flow
Step 1 / 7

CONNECT tunnel

Browser sends HTTP CONNECT host:443 to the proxy. Still cleartext HTTP/1.1 on the client→proxy leg.

Links
BrowserHTTP
──────────────►
ProxyParsing CONNECT
──────────────►
Originnot connected yet

The Bottom Line#

A MITM proxy is:

  1. A liar with a certificate
  2. That intercepts sockets at the HTTP layer
  3. Performs TLS handshakes using fake certs signed by a CA you trust
  4. Decrypts traffic into plain HTTP
  5. Forwards it, modified or not
  6. Re-encrypts the response
  7. Sends it back

If you want to understand your network, this is worth building. The implementation is on GitHub: standard-library Go, TLS interception, HTTP/1.1, HTTP/2, and WebSocket paths as described in the README.


Let's Keep Talking#

What network concept have you been trying to understand? What infrastructure term keeps showing up in blog posts but never quite clicks?

Have you built an intercepting proxy before? What surprised you most?

Do share this post, or reach out on GitHub. If you spot a technical mistake here, I'd rather fix it than leave it wrong.

Until next time.

If you want to talk distributed systems, network proxies, or building to understand—find me on GitHub, X, Peerlist, or LinkedIn.

Feedback welcome.


Reference: trust, proxy, and build#

Trust the CA (macOS / Linux)#

bash
go build -o mitm-proxy ./cmd/proxy
./mitm-proxy -v 2
# Certs are created under certs/ on first run — then trust ca.crt (see README).

macOS (example):

bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/ca.crt

Linux (Debian/Ubuntu-style):

bash
sudo cp certs/ca.crt /usr/local/share/ca-certificates/mitm-proxy.crt
sudo update-ca-certificates

Point the browser at the proxy#

HTTP/HTTPS proxy: 127.0.0.1:8080 (or your -addr). Firefox keeps its own trust store—import the CA there even if the OS trusts it.

Example: block ad hosts#

go
chain := middleware.New().
    UseRequest(middleware.BlockHost("doubleclick.net")).
    UseRequest(middleware.BlockHost("pagead2.googlesyndication.com"))

Glossary#

  • MITM: Man-in-the-Middle. The proxy sits between client and server.
  • CA: Certificate Authority. Signs certificates to prove identity (in the Web PKI sense).
  • TLS: Transport Layer Security. HTTPS runs on TLS.
  • Handshake: Establishing keys and authenticating the server (and optionally the client).
  • Root certificate: Top-level CA cert in a chain; if trusted, signatures under it validate.
  • Leaf certificate: End-entity cert for a specific hostname.
  • SPKI: Subject Public Key Info — the certificate field that holds the server’s public key (algorithm + key bits).
  • Pinning: Enforcing that TLS uses an expected cert or SPKI hash; stricter than “any trusted CA.”
  • Hijack: Taking the TCP connection from the HTTP server stack for raw reads/writes.
  • Round-trip: Send a request and read the full response.
  • Middleware: Hooks that run before/after forwarding.
  • HTTP/1.1: Textual HTTP with optional keep-alive on one connection.
  • HTTP/2: Binary, multiplexed HTTP over one connection.
  • WebSocket: HTTP upgrade to a persistent framed channel.
  • Keep-alive: Reusing the same TCP connection for multiple HTTP requests on that socket.