A Python proxy server needs two different code paths, not one. Plain HTTP requests arrive as a full request line the server can forward directly; HTTPS requests arrive as a CONNECT request that the server must tunnel opaquely instead of parsing.
asyncio handles many simultaneous connections without a thread-per-connection ceiling. Five concurrent requests through the proxy built in this guide completed in under 40ms total, with no request blocking another.
The CONNECT method is what makes HTTPS work through a proxy at all, and it's the step most from-scratch tutorials skip or leave untested — this one implements it and proves it against a real HTTPS site.
Adding Proxy-Authorization: Basic support turns an open relay into an authenticated one, and the difference is verifiable: wrong or missing credentials return a real 407, correct ones return 200.
A self-hosted proxy has exactly as many exit IPs as machines you run it on — usually one. That's fine for local development or a single-region relay, but it's the wall you hit if the goal is spreading requests across many IPs.
Nothing here decrypts or inspects HTTPS traffic. The CONNECT tunnel relays encrypted bytes as-is, which is the correct, least-surprising behavior for a personal forward proxy and avoids having to manage TLS certificates for interception.
Introduction: writing your own forward proxy instead of just using one
A forward proxy sits between a client and the internet, accepting the client's requests and making them on its behalf. Building one in Python is a genuinely different exercise from a proxy from a Python script — pointing at someone else's gateway is a few lines; making your own process correctly relay both plain HTTP and HTTPS, handle multiple clients at once, and reject unauthorized use is where most from-scratch tutorials stop short. This guide builds that server end to end in Python's standard library, tests every path against a real target, and is honest about where a self-hosted relay's limits show up in practice.
The build here uses only asyncio, which ships with Python 3.7+ — no extra packages required for the core server. Everything is verified by running it: every code block below was executed against either a local test server or a real HTTPS site, and the exact commands and results are shown alongside the code, not just described. A self-hosted proxy like this one is also a common building block for network-condition testing and QA workflows where a team wants full visibility into and control over what a request actually does before it leaves the network.
Install: what you need (and don't need)
The proxy server itself has no external dependencies — asyncio, base64, and sys are all part of the standard library on any Python 3.7+ install. Two things are used only for testing, not for the proxy itself:
curl, to drive the proxy from the command line with the -x flag.
The requests library (pip install requests), to confirm the proxy also works as a normal Python HTTP client would expect — the exact combination implied by the keyword "python proxy server," which covers both writing a proxy in Python and driving one from Python code.
Nothing here needs root privileges or a specific OS; the server binds a plain TCP socket on 127.0.0.1 in the examples, and swapping in 0.0.0.0 (with a firewall rule limiting who can reach it) is the only change needed to accept connections from other machines.
Take a Quick Look
If the goal is routing traffic through many exit IPs rather than learning how a proxy works internally, Nstproxy's gateway gives you a ready-made `host:port` to point HTTP/SOCKS5 clients at instead of maintaining this relay code yourself.
Configure the listening socket and request parsing
A forward proxy needs to do three things for every connection: accept it, read enough of the request to know where it's going, and either tunnel or relay accordingly. asyncio.start_server handles the accept step and hands each connection a (reader, writer) pair:
import asyncio
asyncdefhandle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): first_line =await reader.readline()ifnot first_line: writer.close()return header_lines =[first_line.decode(errors="replace").rstrip("\r\n")]whileTrue: line =await reader.readline()if line in(b"\r\n",b"\n",b""):break header_lines.append(line.decode(errors="replace").rstrip("\r\n")) method, target, _ = header_lines[0].split(" ",2)# method is "CONNECT" for HTTPS, or "GET"/"POST"/etc. for plain HTTP
The request line is the branch point: CONNECT host:port HTTP/1.1 means the client wants an HTTPS tunnel and never intends the proxy to read its actual traffic; anything else is a plain-HTTP request the proxy can parse and forward on its own.
For a non-CONNECT request, the target is either in the request line itself (absolute-form, GET http://host:port/path HTTP/1.1) or in the Host: header. The proxy dials that destination, rebuilds the request without the Proxy-* headers a real server wouldn't expect, and pipes bytes in both directions:
Inside handle_client, the non-CONNECT branch parses the target and rebuilds the request:
if target.startswith("http://"): rest = target[len("http://"):] host_port, _, path = rest.partition("/") path ="/"+ path
else: path = target
host_port =next((h.split(":",1)[1].strip()for h in header_lines[1:]if h.lower().startswith("host:")),None,)host, _, port = host_port.partition(":")port =int(port or80)remote_reader, remote_writer =await asyncio.open_connection(host, port)rebuilt =f"{method}{path} HTTP/1.1\r\n"for h in header_lines[1:]:ifnot h.lower().startswith("proxy-"): rebuilt += h +"\r\n"rebuilt +="\r\n"remote_writer.write(rebuilt.encode())await remote_writer.drain()await asyncio.gather(pipe(remote_reader, writer), pipe(reader, remote_writer))
Tested against a throwaway local HTTP server (http.server, bound to 127.0.0.1:9000, returning a fixed body) with the proxy listening on 127.0.0.1:8080:
That returned body hello-from-local-target and HTTP_STATUS:200. Confirmed with curl -v that the request actually traveled through the proxy (> GET http://127.0.0.1:9000/ HTTP/1.1 sent to port 8080, response relayed back) rather than curl connecting to the target directly — a real risk in any sandboxed test environment where a no_proxy setting can silently bypass a proxy for certain hosts, so verifying the actual path taken matters more than trusting the final status code alone.
Advanced patterns: HTTPS tunneling, authentication, and concurrency
Tunneling HTTPS with CONNECT
A proxy that only handles the case above cannot carry HTTPS traffic — the client is about to start a TLS handshake with the destination, and the proxy has no business (or ability, without a private key for the target site) inspecting that. The fix is the CONNECT method: the proxy opens a raw TCP connection to the requested host:port, replies 200 Connection Established, and from that point on just shuttles bytes both directions without looking at them:
if method =="CONNECT": host, _, port = target.partition(":") port =int(port or443) remote_reader, remote_writer =await asyncio.open_connection(host, port) writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")await writer.drain()await asyncio.gather( pipe(reader, remote_writer), pipe(remote_reader, writer),)
This is the exact mechanism MDN's CONNECT method reference describes: "the CONNECT HTTP method requests that a proxy establish a HTTP tunnel to a destination server, and if successful, blindly forward data in both directions until the tunnel is closed." Tested against a real HTTPS site (not a mock) through the proxy on port 8080:
curl -v on the same command confirmed the full path: CONNECT pypi.org:443 sent to the proxy, 200 Connection Established returned, then SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384, and finally an HTTP/2 200 response from pypi.org itself — the TLS handshake happened end-to-end between curl and pypi.org through the tunnel, with the proxy never seeing plaintext. The same URL also worked from Python's requests library pointed at the proxy via proxies={"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}, returning 200 and valid JSON — confirming the server behaves like a proxy to a real HTTP client library, not just to curl.
Requiring authentication
An open relay on the public internet gets abused quickly. Adding Basic auth means checking a Proxy-Authorization header before doing any forwarding, and returning 407 (the proxy-specific equivalent of 401) when it's missing or wrong:
import base64
AUTH = base64.b64encode(b"devuser:s3cret").decode()# normally loaded from config, not hardcodeddefcheck_auth(headers:list[str])->bool:for h in headers:if h.lower().startswith("proxy-authorization:"): value = h.split(":",1)[1].strip()if value.startswith("Basic "):return value[len("Basic "):].strip()== AUTH
returnFalseasyncdefsend_407(writer: asyncio.StreamWriter): body =b"Proxy Authentication Required" writer.write(b"HTTP/1.1 407 Proxy Authentication Required\r\n"b'Proxy-Authenticate: Basic realm="proxy"\r\n'b"Content-Length: "+str(len(body)).encode()+b"\r\n"b"Connection: close\r\n\r\n"+ body
)await writer.drain() writer.close()
Live results with the auth-enabled server on port 8081:
Request
Result
No Proxy-Authorization header
407 Proxy Authentication Required
Wrong credentials (devuser:wrongpass)
407 Proxy Authentication Required
Correct credentials, plain HTTP target
200, body relayed correctly
Correct credentials, HTTPS target via CONNECT
200
Both failure cases were reproduced by actually sending wrong or absent credentials, not asserted from reading the code — the same discipline this project applies to every proxy-configuration claim it publishes.
Concurrency without a thread per connection
Because handle_client is a coroutine, asyncio's event loop runs many connections concurrently on a single thread instead of spawning an OS thread per client, which is the pattern most from-scratch proxy tutorials use instead. Five simultaneous requests through the same running proxy instance:
That printed req1:200 req2:200 req3:200 req4:200 req5:200 and real 0m0.033s. All five completed in 33 milliseconds with no request waiting on another — the behavior documented for asyncio's stream APIs, where start_server's callback runs once per connection as an independent coroutine rather than a blocking call.
Everything above is a real, working forward proxy — and it still has boundaries worth knowing before relying on it for anything beyond local development or a single-region relay.
The most concrete one shows up the moment a target site starts blocking the proxy's IP: this server has exactly one exit IP, the address of the machine it runs on, so a block there blocks every client behind it until that IP changes. That's the point where a rotating gateway becomes the more direct tool for the job rather than the server built in this guide — Nstproxy's Residential Lite line runs many independent residential exit IPs behind a single host:port, using the same HTTP/SOCKS5 client connection format documented for the gateway, so a request that would otherwise stall on one blocked IP goes out on a different one instead, and the client-side code doesn't need to know a rotation happened. It's billed under a prepaid, pay-as-you-go model on the Residential Lite pricing page rather than a fixed subscription, and built for scripts and services that need to spread traffic across a large, geographically varied IP pool (200+ countries and regions, per Nstproxy's own explainer on how HTTP proxies work) rather than for teams that specifically want to own and modify the relay code itself — if inspecting or logging traffic at the proxy layer is the actual goal, a self-hosted server like the one above is still the right tool, and a rotating gateway is a complementary upstream hop, not a replacement for it.
Large, distributed exit-IP pool — many independent residential IPs behind the gateway mean a single blocked or rate-limited IP doesn't stall an entire job the way it would on this self-hosted server's one address.
Same client-side protocol shape — the gateway speaks HTTP, HTTPS, and SOCKS5 on one endpoint, so code written against this guide's proxy (or against requests' proxies dict) points at the gateway with the same connection code, just a different host:port.
Global exit regions — useful when a target site's content, availability, or rate limits vary by requester location, which a single self-hosted server can't replicate without deploying an instance in every region itself.
Two things this build deliberately does not do, and shouldn't be assumed to do without more work: it does not decrypt or inspect HTTPS payloads (the CONNECT tunnel is opaque by design, which is correct for a personal relay and avoids managing TLS certificates for interception), and it does not persist logs, rate-limit clients, or enforce access lists beyond the single Basic-auth credential shown — a proxy exposed beyond 127.0.0.1 needs at least per-client credentials and connection limits before it's safe to leave running unattended.
Troubleshooting common errors
curl: (7) Failed to connect almost always means the proxy process isn't listening on the address/port curl was given, or a firewall is blocking that port — confirm with ss -tlnp | grep <port> that something is actually bound there before checking the proxy code.
A request seems to succeed even with the wrong proxy port, or wrong credentials appear to work — check whether no_proxy/NO_PROXY is set in the shell's environment and includes the target host; several sandboxed and CI environments set this by default, and curl or requests will silently bypass the configured proxy entirely for any host on that list. Run env -u no_proxy -u NO_PROXY in front of the test command to rule this out, and confirm with curl -v that the request line shows the proxy's port, not a direct connection.
502 Bad Gateway from this proxy means the proxy failed to reach the destination host — check that the target hostname resolves and the port is reachable from the machine running the proxy, not from the client.
HTTPS works but plain HTTP doesn't (or vice versa) — this almost always traces back to the request-line branch: confirm the client is actually sending CONNECT for HTTPS (some HTTP client libraries need an explicit https proxy entry, separate from http, before they'll do this) and an absolute-form request line or a Host: header for plain HTTP.
Conclusion
A working Python proxy server comes down to two request shapes handled differently — plain HTTP forwarded and rebuilt, HTTPS tunneled opaquely via CONNECT — plus whatever authentication and concurrency handling the deployment actually needs. Every piece of that, including the parts most quick tutorials skip (a real HTTPS tunnel, a real 407, real concurrent requests), was run against a live target in this guide rather than described from memory. Where this build's single-exit-IP ceiling becomes the actual bottleneck is the point to reach for a managed rotating gateway instead of scaling this code further.
Q: Can a Python proxy server you build yourself handle HTTPS traffic?
Yes, but only by implementing the CONNECT method and tunneling the encrypted bytes without inspecting them — a proxy that only parses plain-HTTP request lines (the common first-tutorial version) cannot carry HTTPS, since the client never sends the destination's real request through it in cleartext.
Q: Is it legal to run your own proxy server?
Running a proxy server itself is legal; what matters is what it's used for and whether the traffic passing through it is authorized — using a self-built or third-party proxy to bypass access controls, scrape data against a site's terms, or hide unlawful activity carries legal risk regardless of whose code is doing the relaying.
Q: Why does a proxy need to support the CONNECT method specifically, instead of just forwarding HTTPS requests like HTTP ones?
Because an HTTPS request is encrypted before it leaves the client, so a proxy handling it the way it handles plain HTTP would need to see cleartext it never receives; CONNECT sidesteps that by having the proxy blindly relay bytes after the tunnel opens, so the TLS handshake happens directly between the client and the real destination.
Q: How does a proxy server you build yourself differ from a commercial rotating proxy service?
A self-hosted server like the one in this guide has exactly as many exit IPs as machines it runs on — typically one — while a commercial rotating gateway sits in front of a large, managed pool of IPs and swaps which one handles each request, which matters once IP-based blocking or rate limits (not code complexity) become the actual constraint.
Q: Does this proxy work with Python's requests library, or only with curl?
Both — the server speaks plain HTTP proxying and CONNECT tunneling at the protocol level, so any client that implements those correctly, including requests via its proxies argument, curl -x, and browsers, can use it without special handling.
Ivy Lin
Aug. 6th 2026
110M+ real IPs with 99.9% access success
Blazing-fast average response ~0.5s for high-concurrency tasks
From only $0.1/GB
Get immediate access to premium residential, datacenter, IPv6 and ISP proxy pools.