# Building a MITM Proxy from Scratch: The Journey Behind mitm-core

## Every networking project starts with "I'll just forward a request." A MITM proxy quickly teaches you that nothing on the Internet is ever that simple.

### Introduction

I didn't start [`mitm-core`](https://github.com/debanshup/mitm-core) with the goal of building another proxy framework.

In fact I had a much simpler question:

> **How does an HTTPS request actually work under the hood?**

Every day we type a URL into a browser, and within miliseconds a secure connection is established. We know the high-level concepts — TLS, certificates, encryption — but what really happens between clicking **Enter** and seeing a webpage?

I wanted to understand every step of that journey.

So I decided to build a simple MITM (Man-in-the-Middle) proxy from scratch. The idea wasn't to create a production-ready tool or compete with existing projects. It was purely an experiment—a way to learn by rebuilding the entire flow myself.

I expected to forward a few requests, generate some certificates, and call it a day.

I was completely wrong.

Every answer uncovered two more questions. A simple HTTP proxy became HTTPS interception. HTTPS interception led to certificate generation. Certificate generation led to TLS handshakes. Then came HTTP/2, ALPN negotiation, streaming, connection lifecycles, caching, plugins, and countless edge cases that browsers handle every second without us ever noticing.

What started as a weekend experiment gradually evolved into **mitm-core**—a modular, extensible MITM framework that taught me far more about networking than I ever expected.

In this article, I'll share that journey, the mistakes I made, the problems I encountered, and why they eventually led me to redesign the entire architecture from the ground up.

* * *

### What is a MITM Proxy?

Imagine you send a letter to your friend.

Normally, the journey looks like this:

```plaintext
You ─────────────► Friend
```

Now imagine there's someone sitting in the middle.

Instead of sending the letter directly, it goes through them first.

```plaintext
You ─────► Middle Person ─────► Friend
```

The middle person can:

*   Read the letter
    
*   Modify its contents
    
*   Block it entirely
    
*   Or simply forward it without making any changes
    

A **Man-in-the-Middle (MITM) Proxy** works in exactly the same way—but instead of letters, it sits between a client (such as your browser) and a server, intercepting network traffic.

```plaintext
Browser ─────► MITM Proxy ─────► Website
```

From the browser's perspective, it's communicating directly with the website.

From the website's perspective, it's communicating directly with the browser.

The proxy quietly sits in the middle, forwarding data between both sides while having complete visibility into the communication.

This makes MITM proxies incredibly useful for tasks such as:

*   Debugging HTTP requests and responses
    
*   Inspecting API traffic
    
*   Security testing
    
*   Performance analysis
    
*   Building developer tools
    

Of course, intercepting encrypted HTTPS traffic isn't as straightforward as plain HTTP. Modern browsers expect to communicate securely with the server, so the proxy has to convince both sides that it's the legitimate endpoint.

That's where things start getting interesting—and much more complicated.

* * *

### The rabbit hole

At first, the plan seemed simple.

I thought I'd create a proxy that accepted an HTTP request, forwarded it to the destination server, received the response, and sent it back to the client.

Simple enough.

That illusion lasted only a few hours.

The moment I moved from HTTP to HTTPS, I discovered that I wasn't just forwarding requests anymore—I was participating in the entire communication process.

To inspect encrypted traffic, the proxy first has to establish a secure connection with the client. At the same time, it also needs another secure connection with the actual server. Somehow, it has to sit between both without either side realizing they're not talking directly to each other.

That was only the beginning.

One solved problem immediately revealed another:

*   How do browsers decide whether to trust a certificate?
    
*   How are certificates generated on the fly for every domain?
    
*   What exactly happens during a TLS handshake?
    
*   How does the `CONNECT` method establish an HTTPS tunnel?
    
*   How does a proxy decrypt HTTPS traffic without the browser complaining?
    
*   How should requests and connections be managed throughout their lifecycle?
    
*   How can plugins intercept traffic without breaking the flow?
    
*   How do you handle errors, caching, and resource cleanup correctly?
    

Every answer uncovered another layer of the networking stack.

What I initially thought was a single protocol turned out to be an ecosystem of protocols, handshakes, streams, state machines, and edge cases—all working together to make something as simple as loading a webpage feel instantaneous.

By this point, the project had stopped being "just a proxy."

It had become a deep dive into how modern web communication actually works. And before I realized it, I was no longer just learning HTTPS—I was designing the architecture needed to support it.

* * *

### Designing the first architecture

By this point, it was obvious that a simple "receive a request and forward it" approach wasn't going to scale.

Every connection had to go through multiple stages. A raw TCP connection wasn't the same as an HTTPS handshake, and an incoming request shouldn't be handled the same way as an outgoing response.

Instead of putting everything inside one giant function, I decided to split the proxy into a series of well-defined phases.

The idea was simple: each phase would have a single responsibility and execute in a predictable order.

![](https://cdn.hashnode.com/uploads/covers/69cdf6922d4d5bd0f080b8d3/7a858ab5-378f-4b5e-99d9-8373f74768e9.png align="center")

Each handler was responsible for only one stage of the connection lifecycle.

*   **TCP Handler** accepted and prepared new connections.
    
*   **Handshake Handler** established secure communication for HTTPS traffic.
    
*   **Request Handler** processed incoming requests before forwarding them upstream.
    
*   **Response Handler** intercepted the upstream response before sending it back to the client.
    

To keep the system extensible, the entire pipeline was driven by events. Plugins could subscribe to different stages without modifying the core implementation. This separation made the codebase easier to reason about and allowed new functionality to be added with minimal changes to the existing flow.

At the time, this architecture felt like the right balance between simplicity and flexibility. It was modular, easy to extend, and much cleaner than a monolithic request handler.

It also laid the foundation for everything that came next—even though, as I would later discover, it had limitations that only became visible as the project grew.

* * *

### The engineering problems

The first architecture worked.

It accepted connections, decrypted HTTPS traffic, forwarded requests, intercepted responses, and allowed plugins to hook into different stages of the lifecycle.

But as the project grew, the cracks started to appear.

Most of these weren't bugs—they were architectural limitations that became obvious only after adding more features.

*   **One Context for Everything**
    

The biggest issue was that everything lived inside a single `ProxyContext`.

Connection state, request state, certificates, routing information, temporary variables, plugin data—everything shared the same object.

Initially, this felt convenient.

Over time, it became increasingly difficult to determine which data belonged to the connection, which belonged to a single request, and which was only needed for a specific stage of the pipeline.

Plugins frequently mutated the same object, making state propagation harder to reason about and increasing coupling between different parts of the system.

*   **Lifecycle Management Became Messy**
    

As more features were added, managing the lifecycle of sockets and requests became increasingly complex.

There wasn't a clear separation between connection setup, request processing, response handling, and cleanup.

That led to several issues I spent a considerable amount of time debugging:

*   Sockets remaining open longer than expected.
    
*   `CLOSE_WAIT` connections caused by sockets not being cleaned up properly.
    
*   Premature socket termination.
    
*   Exceptions such as:
    

```plaintext
Error: Can't write to socket after response has been sent
```

None of these problems were particularly difficult on their own, but together they made the codebase harder to maintain.

*   **The Event System Didn't Scale**
    

The project relied heavily on events, but the original event manager wasn't statically typed.

As the number of events increased, it became easier to subscribe to incorrect event names, pass invalid payloads, or accidentally introduce inconsistencies that TypeScript couldn't detect.

The implementation also made listener management more difficult than it needed to be, increasing the risk of lingering listeners and unnecessary memory usage.

*   **Resource Management**
    

Network programming is fundamentally about managing resources.

Every accepted connection creates sockets.

Every HTTPS request introduces additional streams and state.

Every missed cleanup eventually consumes memory.

I learned this lesson the hard way.

A small oversight in socket cleanup could leave connections stuck in `CLOSE_WAIT`, gradually increasing resource usage until the proxy became unstable.

These weren't problems that could be solved with another `try...catch` block or a few extra conditionals.

They pointed to something deeper.

The architecture itself had reached a point where adding new functionality was becoming harder than it should have been.

And that's when I started asking a different question:

> **Instead of fixing individual problems, what if I redesigned the architecture so these problems became much harder to create in the first place?**

* * *

### The realization

After fixing countless bugs, refactoring handlers, and improving different parts of the proxy, I noticed something interesting.

Most of the problems weren't caused by the individual components.

The certificate generation worked.

The request forwarding worked.

The plugin system worked.

The handlers worked.

Yet every new feature seemed to make the project a little harder to reason about.

I found myself spending more time managing interactions between components than actually building new functionality.

That was the moment I realized I was solving symptoms rather than the underlying problem.

The architecture that had helped me get the project off the ground was now limiting its growth.

Instead of asking,

> *"How do I fix this bug?"*

I started asking,

> *"Why is this bug possible in the first place?"*

That single question changed how I looked at the project.

Rather than continuing to patch the existing design, I decided to rethink the architecture from first principles.

If different kinds of state had different lifetimes, they shouldn't live in the same object.

If every stage had its own responsibility, it should also have its own lifecycle.

If resources needed explicit cleanup, the architecture should make cleanup a natural part of execution—not an afterthought.

That decision marked the end of the first generation of **mitm-core**.

The next version wasn't about adding more features.

It was about building a stronger foundation that would make future features easier to implement, easier to maintain, and far less error-prone.

In the next article, I'll walk through that redesign and explain how the new architecture emerged from these lessons.
