What is a backconnect proxy? How the gateway works

You loop over a requests.Session, hit an IP-echo endpoint 50 times through your new "rotating residential" plan, and get the same address back 50 times. The dashboard says rotation is on. Your terminal says it isn't.

The usual reaction is a support ticket and a search for another provider. The cause is usually in your own code: the client kept one tunnel open, and a backconnect proxy gateway only picks a new exit when a new connection arrives.

In this guide, I'll break down how the gateway works, how a backconnect proxy differs from the terms it gets lumped in with, and how to build one yourself in about 60 lines of Python.

What is a backconnect proxy?

A backconnect proxy is a single gateway address that routes your traffic through a pool of exit IPs behind it. You connect to one host and port; the gateway picks the exit IP per connection, per request, or per session ID you pass in the username. Use one when you need many IPs without maintaining a proxy list.

Your code only ever knows four things: a hostname, a port, a username, and a password. Everything that changes, including which IP the target sees, happens on the provider's side of that gateway.

The target never sees the gateway. It sees the exit, and on the next connection it may see a different one.

Why it's called "backconnect"

The name makes the most sense once you look at residential networks. A home device serving as an exit sits behind a router that blocks incoming connections, so the provider can't dial it directly.

Instead, the exit dials out to the provider and holds that line open. When your request arrives, the gateway sends it back down that existing connection. The connection runs backwards from what you'd expect, hence "back" connect.

It's industry jargon with no RFC behind it. Datacenter pools use the same gateway design even though their exits accept direct connections fine.

Backconnect proxy vs. rotating proxy vs. proxy list

Plenty of vendor pages use these terms as synonyms. They describe different layers of the same setup, and mixing them up leads to the wrong fix when something breaks.

Term What it is When you'd use it Example
Backconnect proxy An architecture: one gateway, a pool of exits behind it, selection done by the provider You want many IPs and don't want to own the list gateway.example.com:8000 with -country-us in the username
Rotating proxy A behavior: the exit IP changes over time Spreading bulk requests across IPs A backconnect gateway in its default mode
Proxy rotation A technique your own code performs You hold a list and choose from it yourself itertools.cycle(proxy_list)
Proxy list Individual ip:port endpoints you manage You need exact control over which IP does what 50 static datacenter IPs in a CSV
Sticky session A gateway rule pinning one exit to a session ID for a window Logins, carts, paginated search flows -session-cba5159a held for 10 minutes

So a backconnect proxy can run in rotating mode or sticky mode. And you can rotate without any backconnect gateway at all, by cycling through a list in your own code.

Say you check rental listings in the US and Germany every night. The backconnect proxy is the gateway both jobs point at. The US crawl runs in rotating mode, so each listing page leaves through a different exit.

The German job uses a sticky session, because that site gets suspicious when page 2 of a search arrives from a different city than page 1.

Meanwhile, a partner API that allowlists five fixed IPs gets a proxy list, and your code does its own proxy rotation across those five.

What's inside the gateway: four parts

Whoever runs it, a backconnect proxy has four moving parts. Once you can name them, most "rotation is broken" bugs point straight at one of them.

1. The gateway

The gateway is the one address in your config. It may be dozens of machines behind a DNS name, but your client sees one ordinary HTTP proxy.

For HTTPS targets, your client sends a CONNECT request asking the gateway for a raw tunnel to host:443 (RFC 9110 defines the method).

Once the tunnel is up, your TLS handshake runs end to end with the target. The gateway and the exit only move encrypted bytes.

Your credentials are a different story. They travel in a Proxy-Authorization header as Base64, which is encoding, not encryption.

Where you'll hit this:

  • Debugging a 407 that your library reports as a vague ProxyError
  • Checking whether your username parameters reached the gateway intact
  • Explaining to security why proxy credentials shouldn't cross untrusted networks

How it works in practice: run curl -v through the gateway and read the handshake. This trimmed output comes from the test gateway built further down.

$ curl -v -x http://alice-country-us-session-ab12:[email protected]:8000 \
    https://api.ipify.org
> CONNECT api.ipify.org:443 HTTP/1.1
> Host: api.ipify.org:443
> Proxy-Authorization: Basic YWxpY2UtY291bnRyeS11cy1zZXNzaW9uLWFiMTI6czNjcmV0
< HTTP/1.1 200 Connection established

$ echo YWxpY2UtY291bnRyeS11cy1zZXNzaW9uLWFiMTI6czNjcmV0 | base64 -d
alice-country-us-session-ab12:s3cret

A 407 Proxy Authentication Required on the < line means the gateway rejected your credentials before any exit got involved. Anything that happens after 200 Connection established is between you and the target.

2. The username (the control channel)

Providers pack targeting and session options into the username because it's the one proxy field that curl, Requests, browsers, and Playwright all let you set. Custom headers on CONNECT are awkward in most libraries.

The idea is the same across providers; the spelling differs. These are real formats from provider docs, all saying "US exit, session abc123, hold for 10 minutes":

Parameter style Example username
Hyphen pairs user-country-us-session-abc123
Short codes plus TTL customer-user-cc-US-sessid-abc123-sesstime-10
Underscore flags user_c_US_s_abc123_ttl_10m
Lifetime in seconds user-session-123456-lifetime-600

The gateway splits the username on its delimiter and reads the pairs. That makes the delimiter a reserved character, which matters more than it sounds (see the pitfalls section).

How it works in practice: build the URL in one helper instead of hand-assembling strings across your codebase. Keep the secrets in environment variables.

import os
import secrets
from urllib.parse import quote

def proxy_url(country=None, session=None):
    user = os.environ["PROXY_USER"]
    if country:
        user += f"-country-{country}"
    if session:
        user += f"-session-{session}"
    password = quote(os.environ["PROXY_PASS"], safe="")  # '@' or ':' would break the URL
    return f"http://{user}:{password}@{os.environ['PROXY_GATEWAY']}"

sticky = proxy_url(country="us", session=secrets.token_hex(4))  # hex has no '-' to confuse the parser
rotating = proxy_url(country="us")

Swap the two f-string suffixes to match your provider's format and nothing else changes. token_hex gives you IDs like cba5159a that can't contain the delimiter.

3. The exit selector

This is the part that decides which exit handles your traffic. Two rules cover most gateways: no session ID means a fresh pick, and a session ID means "give me the same exit as last time."

Sticky sessions are usually a hash. The gateway hashes your session ID onto the pool, so the same ID lands on the same exit until it expires or the exit goes offline.

The part that trips people up is when a fresh pick happens. Many gateways choose once per TCP connection, and HTTP keep-alive holds your CONNECT tunnel open across requests.

Where you'll hit this:

  • A requests.Session or httpx.Client that reports the same IP on every call
  • A headless browser that loads 40 assets from one exit
  • Rotation that "works in curl but not in my script"

How it works in practice: this script runs the same request six times through a shared Session, then six times with a fresh connection each.

import requests

proxy = "http://USER:[email protected]:8000"
proxies = {"http": proxy, "https": proxy}
url = "https://api.ipify.org"

s = requests.Session()
print("session:", [s.get(url, proxies=proxies, timeout=15).text for _ in range(6)])

# requests.get() builds a throwaway Session, so each call opens a new tunnel
print("fresh:  ", [requests.get(url, proxies=proxies, timeout=15).text for _ in range(6)])

Against the per-connection gateway built below (exit IPs swapped for documentation addresses), I got:

session: ['203.0.113.24', '203.0.113.24', '203.0.113.24', '203.0.113.24', '203.0.113.24', '203.0.113.24']
fresh:   ['203.0.113.24', '203.0.113.24', '198.51.100.9', '203.0.113.87', '203.0.113.87', '198.51.100.9']

Six calls, one exit, because the Session reused its tunnel. Notice the fresh list still repeats: random picks from a small pool collide, so a repeated IP alone doesn't prove rotation is off.

4. The exit pool

"Backconnect" says nothing about the kind of IPs behind the gateway. That choice changes how exits reach the gateway, and with it how reliable a sticky session is.

Exit type Where the IP comes from How the exit reaches the gateway What that means for you
Residential Home ISP connections Dials out from behind a home router and holds the line open Sticky sessions end early when the device goes offline
Mobile Carrier networks, often behind CGNAT Dials out, like residential Many real users share each IP, so blocks are costly for the site
Datacenter Hosting providers Gateway connects to it directly Stable and fast, but the ranges are well known
ISP (static residential) ISP-registered IPs hosted in datacenters Gateway connects to it directly Long, stable sessions with a residential-looking ASN

For the sourcing side of residential pools (SDKs, consent, peer churn), see our breakdown of how residential proxies work.

How it works in practice: check what you're paying for. The ASN behind an exit tells you whether it's a home ISP or a cloud range.

import requests

proxy = "http://USER:[email protected]:8000"
info = requests.get("https://ipinfo.io/json", proxies={"https": proxy}, timeout=20).json()

print(info["ip"], info.get("country"), info.get("org"))
# org "AS7922 Comcast Cable Communications, LLC" -> a home ISP
# org "AS16509 Amazon.com, Inc." -> a cloud range, whatever the product page says

Run it 20 times on fresh connections and tally the org values. A "residential" pool returning hosting ASNs deserves a conversation with the provider.

How to set up a backconnect proxy

The setup itself is four fields in a config. Getting it to behave the way you think it does takes a little more discipline.

1. Check whether you need one at all

If you pull a few hundred pages a day from one site, a static proxy and a sane delay will do.

A backconnect proxy adds per-GB billing, an extra network hop, and an exit you can't pick in advance.

Start with the smallest thing that works. Move to a gateway when you're blocked on volume or geography, not before.

2. Put credentials in the environment, options in a helper

Hard-coded proxy strings end up in Git history and screenshots. Keep credentials in environment variables and build every URL through the proxy_url() helper above.

If your provider also supports allowlisting your server's IP, weigh that against passwords with our comparison of IP whitelisting vs. username:password authentication.

3. Verify rotation and stickiness before the real run

Hit an IP-echo endpoint with fresh connections, then with a fixed session ID. Fresh connections should spread across several IPs; the session ID should keep returning one.

Repeat the check whenever you change providers, libraries, or HTTP clients, since any of them can change how connections get reused.

4. Retry on a fresh connection

A blocked response is information: that exit is burned for this target, at least for now. Retrying inside the same kept-alive tunnel sends you straight back to it.

import time
import requests

RETRYABLE = {403, 429, 502, 503}

def fetch(url, proxy, attempts=4):
    proxies = {"http": proxy, "https": proxy}
    for attempt in range(attempts):
        try:
            # Plain requests.get, no Session: every attempt opens a new tunnel -> new exit
            r = requests.get(url, proxies=proxies, timeout=20)
            if r.status_code not in RETRYABLE:
                return r
        except (requests.ConnectionError, requests.Timeout) as exc:
            if "407" in str(exc):
                raise  # bad credentials fail every time; retrying burns quota
        time.sleep(2 ** attempt)  # 1s, 2s, 4s, 8s
    return None

Requests reports a rejected CONNECT as ProxyError: ... Tunnel connection failed: 407 Proxy Authentication Required, which is why the check reads the message.

For the full Python setup, including SOCKS and sessions, see our tutorial on using proxies with Python Requests.

Build your own gateway in Python

None of the pages I reviewed for this term show the gateway itself. This section builds a working backconnect proxy in about 60 lines of standard-library Python.

It authenticates you, parses -country- and -session- from the username, picks an exit, and chains your CONNECT through it. The exits are HTTP proxies you control, like squid on a few VPSes.

Paste the blocks in order into gateway.py. First, the pool and the username parser:

import asyncio
import base64
import hashlib
import random

# Your exits: HTTP proxies that support CONNECT (squid or tinyproxy on VPSes you control)
POOL = {
    "us": ["10.0.0.11:3128", "10.0.0.12:3128"],
    "de": ["10.0.1.21:3128"],
}
USERS = {"alice": "s3cret"}


def parse_auth(value):
    """'Basic <b64 of alice-country-us-session-ab12:s3cret>' -> ('alice', {...}, 's3cret')"""
    raw = base64.b64decode(value.split(" ", 1)[1]).decode()
    login, _, password = raw.partition(":")  # usernames can't contain ':', passwords can
    user, *pairs = login.split("-")
    opts = dict(zip(pairs[0::2], pairs[1::2]))  # ['country','us','session','ab12'] -> dict
    return user, opts, password

That split("-") is the entire "username parameter" system you see in provider docs. The zip pairs up keys and values, and anything unpaired gets dropped without a word.

Next, the exit selector. This is where rotation and stickiness come from:

def pick_exit(opts):
    country = opts.get("country")
    candidates = POOL[country] if country else sum(POOL.values(), [])
    session = opts.get("session")
    if session:
        # Same session ID -> same hash -> same exit. That's all "sticky" means here.
        digest = int(hashlib.sha256(session.encode()).hexdigest(), 16)
        return candidates[digest % len(candidates)]
    return random.choice(candidates)  # no session: fresh pick for every new connection

Real gateways add health scores, cooldowns per target domain, and session expiry. The shape stays the same: filter the pool, then hash or pick.

Now the request handler, which rejects anything it can't serve before touching an exit:

async def handle(reader, writer):
    head = (await reader.readuntil(b"\r\n\r\n")).decode()
    request_line, *lines = head.split("\r\n")
    method, target, _ = request_line.split(" ")
    headers = {k.lower(): v for k, _, v in (l.partition(": ") for l in lines if l)}

    if method != "CONNECT":
        return await reply(writer, "405 Method Not Allowed")  # HTTPS targets only
    user, opts, password = parse_auth(headers.get("proxy-authorization", "Basic Og=="))
    if USERS.get(user) != password:
        return await reply(writer, "407 Proxy Authentication Required")
    if opts.get("country") and opts["country"] not in POOL:
        return await reply(writer, "502 No Exit For Country")
    await tunnel(reader, writer, pick_exit(opts), target)

Og== is Base64 for a lone :, so a request without credentials parses as an empty user and fails cleanly. Only CONNECT is supported, which covers HTTPS targets.

The tunnel chains your connection through the chosen exit. The small reply() helper at the bottom sends every error response:

async def tunnel(reader, writer, exit_addr, target):
    host, port = exit_addr.split(":")
    up_reader, up_writer = await asyncio.open_connection(host, int(port))
    # Ask the exit to open its own tunnel to the target
    up_writer.write(f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n".encode())
    await up_writer.drain()
    if b" 200 " not in await up_reader.readuntil(b"\r\n\r\n"):
        return await reply(writer, "502 Exit Refused")
    writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
    await writer.drain()
    # From here on it's opaque TLS bytes in both directions
    await asyncio.gather(pipe(reader, up_writer), pipe(up_reader, writer))


async def reply(writer, status):
    writer.write(f"HTTP/1.1 {status}\r\n"
                 'Proxy-Authenticate: Basic realm="gw"\r\n\r\n'.encode())
    await writer.drain()
    writer.close()

The exit dialing happens once per incoming connection. That single line of open_connection is why the requests.Session in the hook kept its IP.

Finally, the byte pump and the server:

async def pipe(src, dst):
    try:
        while chunk := await src.read(65536):
            dst.write(chunk)
            await dst.drain()
    except ConnectionError:
        pass  # one side hung up mid-stream
    finally:
        dst.close()


async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 8000)
    async with server:
        await server.serve_forever()


if __name__ == "__main__":
    asyncio.run(main())

Run python3 gateway.py, then point curl at it with -x http://alice-session-ab12:s3cret@localhost:8000. Change the session ID and watch the exit change.

I tested this against three local exits bound to separate loopback addresses. Twenty concurrent workers sent 200 requests; all 200 succeeded, split 77/69/54 across the exits.

Fixed session IDs returned the same exit every time. A wrong password got a 407, and -country-fr got a 502.

What it leaves out: health checks, session expiry, rate limits per user, plain-HTTP forwarding, and TLS on the listening port. For a maintained open-source option that puts a rotating gateway in front of your own list, look at mubeng.

Run your own when you already own the exits and want control over selection logic. When maintaining exits stops being worth your weekends, hosted pools like Roundproxies' residential network expose the same gateway interface, so your client code stays the same.

Gateway setups for three common jobs

The same gateway gets configured very differently depending on the job. Three common ones:

Nightly price checks across 20,000 SKUs

No session state, lots of volume, per-IP rate limits everywhere. Rotating mode is the fit, and the only real decision is how your client opens connections.

Call requests.get() directly, or give each worker a fresh Session every few dozen requests. Residential pools usually bill per GB, so fetch a product's JSON endpoint when the site has one.

Localized SERP and ad checks

Here the country parameter matters more than rotation. Pass it, then confirm with the ASN check above that the exit's country matches what you asked for.

City-level targeting is less exact than it looks. Geolocation databases disagree, so the target may place your "Chicago" exit elsewhere in Illinois.

Multi-step flows behind a session

Search filters, carts, and paginated results need a sticky session. Generate one session ID per logical user, not per request, and keep that user's cookies in their own Session object.

Plan for the exit to disappear. A residential exit is someone's home connection; when the router reboots, your session moves to a new IP mid-flow. Make the flow restartable from step one.

Pitfalls and how to avoid them

Keep delimiters out of your session IDs

If your gateway splits the username on hyphens, a session ID containing a hyphen corrupts the parse. Feeding two usernames into the parser above shows what happens:

alice-session-job-7  ->  {'session': 'job'}
alice-session-job-8  ->  {'session': 'job'}

Both workers now share session job, and therefore one exit. Nothing errors, and your rotation quietly collapses to a single IP. Use secrets.token_hex() or plain alphanumerics for session IDs.

Use http:// for the gateway, even for HTTPS targets

Your client talks to the gateway in plain HTTP and sends CONNECT for HTTPS sites. The https:// in the target URL is what gets encrypted end to end.

Setting the proxy URL itself to https://gateway... makes your client attempt TLS with a port that doesn't speak it. Against the test gateway, Requests hung, then threw ProxyError: ... Read timed out.

Other gateways answer with an SSLError. Use https:// for the proxy only when your provider documents a TLS port.

Check your environment before trusting session.proxies

Requests merges proxy settings from variables like HTTPS_PROXY, and those beat session.proxies. On Requests 2.33, with a dead proxy in session.proxies and a working one in the environment, traffic used the environment's.

Pass proxies= on each request, or set session.trust_env = False to ignore the environment. The Requests proxy docs cover the precedence rules.

Residential gateways usually bill per gigabyte, and every retry through a fresh exit downloads the page again. A crawler that retries each 403 four times can quietly triple its bill on a hard target, while the logs only show "eventually succeeded." Log the attempt count next to each response and set a per-domain retry cap. If one site needs three or more attempts on average, that is a signal to slow down, fix the client fingerprint, or fetch a lighter JSON endpoint instead of paying for the same blocked page over and over.

Don't expect the gateway to fix fingerprinting

A backconnect proxy changes the IP the target sees and nothing else. If the site blocks you based on your TLS handshake, headers, or browser fingerprint, every exit in the pool gets blocked the same way.

You'll recognize it when a fresh exit gets a 403 on its first request. That's a client problem; start with our explainer on what a TLS fingerprint is.

FAQ

Is a backconnect proxy the same as a rotating proxy?

No, though they usually come together. A backconnect proxy is the architecture (one gateway in front of a pool); a rotating proxy is the behavior (the exit IP changes).

A gateway in sticky mode doesn't rotate, and a script cycling through a proxy list rotates with no gateway at all.

Why is it called a backconnect proxy?

Because residential exits sit behind home routers and can't accept incoming connections. They dial out to the provider and hold the line open, and the gateway sends your traffic back down it.

The term is industry jargon with no formal spec.

Are backconnect proxies residential?

Not necessarily. Residential and mobile pools almost always use a backconnect gateway, but datacenter and ISP proxies can sit behind one too. Check the exit's ASN if the product description is vague.

Are backconnect proxies slower than a direct proxy?

Yes, slightly. Each request takes an extra hop through the gateway, and residential exits add a home connection at the far end.

For bulk scraping it rarely matters. For latency-sensitive work, a static datacenter or ISP proxy fits better.

Can I build my own backconnect proxy?

Yes. The gateway is the easy part, as the 60-line version above shows.

The hard part is the exits: you need IPs you control, whether that's a few VPSes or static proxies you already rent.

Wrapping up

The mental model to keep: a backconnect proxy is a gateway that picks an exit per new connection and reads your options from the username.

Most rotation bugs trace back to your client reusing connections or mangling that username.

Before your next big run, send ten fresh requests and ten same-session requests through the gateway and look at the IPs. Then set up the client properly with our Python Requests proxy tutorial.