reqwest is the HTTP client almost every Rust project reaches for, and its proxy support is clean once you know two things: the proxy lives on the Client you build, not on individual requests, and SOCKS is behind a Cargo feature you have to turn on. This guide covers building a proxied Client, authentication, SOCKS5 with the socks feature, the environment variables reqwest reads for you, rotating a pool with Proxy::custom, and the verification step that proves the proxy is carrying your traffic.
We run a proxy network and write the Rust client for it, so the mistakes below are ones we have made ourselves: a SOCKS URL that errored because the socks feature was not enabled, a Client that quietly used a shell proxy because reqwest reads ALL_PROXY by default. Every example is copy-paste runnable. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns real, recently checked endpoints without a key.
How do you use a proxy with Rust?
Build a reqwest::Client with a Proxy attached: Client::builder().proxy(Proxy::all("http://host:port")?).build()?. Proxy::all routes both HTTP and HTTPS through the proxy; Proxy::http and Proxy::https scope it to one scheme. Add .basic_auth(user, pass) for authentication. The proxy is a property of the Client, so build the client once and reuse it.
Building a proxied Client
Start with the dependencies. reqwest's async client needs a runtime, so pair it with tokio:
[dependencies]
reqwest = { version = "0.13", features = ["socks"] }
tokio = { version = "1", features = ["full"] }
The socks feature is only needed if you use SOCKS proxies; it does no harm to leave it on. Now build a Client with a Proxy:
use reqwest::{Client, Proxy};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::builder()
.proxy(Proxy::all("http://203.0.113.7:8080")?)
.timeout(Duration::from_secs(15))
.build()?;
let body = client
.get("https://httpbin.org/ip")
.send()
.await?
.text()
.await?;
println!("{body}");
Ok(())
}
Proxy::all proxies "all traffic to the passed URL," meaning both http and https URLs. For an https:// target, reqwest opens a CONNECT tunnel through the proxy and performs TLS end to end with the origin, so a plain HTTP proxy carries your encrypted traffic without seeing inside it. If you want to proxy only one scheme, Proxy::http("http://...") and Proxy::https("http://...") restrict routing to HTTP or HTTPS targets respectively, and you can add more than one to the same builder.
If your codebase is synchronous, the same API exists under reqwest::blocking behind the blocking feature: reqwest::blocking::Client::builder().proxy(...). The method names are identical, only the .await disappears.
Proxy authentication
Paid proxies authenticate with a username and password. For an HTTP or HTTPS proxy, chain .basic_auth onto the Proxy, which per reqwest's docs sets the Proxy-Authorization header using Basic auth:
let proxy = Proxy::all("http://203.0.113.7:8080")?
.basic_auth("user", "pass");
let client = Client::builder().proxy(proxy).build()?;
A rejected credential comes back as HTTP 407 Proxy Authentication Required, which tells you the proxy is reachable and only the login is wrong. We cover that status in how to fix 407 Proxy Authentication Required.
The environment variables reqwest reads
Worth knowing before it surprises you: reqwest reads system proxy settings from the environment by default. Its documentation is explicit that HTTP_PROXY (or http_proxy) sets the proxy for HTTP connections, HTTPS_PROXY for HTTPS, and ALL_PROXY for both. So a Client::builder().build()? with no .proxy() call is not necessarily going direct, it may be using whatever those variables hold. When you want a client that ignores the environment entirely, say so:
let client = Client::builder()
.no_proxy() // ignore HTTP_PROXY / HTTPS_PROXY / ALL_PROXY
.build()?;
SOCKS5 proxies
SOCKS support is compiled out unless you ask for it, which is why a SOCKS URL errors on a default build. Turn on the socks feature in Cargo.toml (as shown above), and then the SOCKS schemes work anywhere a proxy URL is accepted:
let client = Client::builder()
.proxy(Proxy::all("socks5h://198.51.100.14:1080")?)
.build()?;
let body = client.get("https://httpbin.org/ip").send().await?.text().await?;
println!("{body}");
reqwest recognizes four SOCKS schemes: socks4, socks4a, socks5, and socks5h. The one to reach for is usually socks5h. With plain socks5://, your machine resolves the target hostname and sends the proxy an IP, so your local resolver sees every host you visit and names that only resolve on the proxy's network fail. With socks5h:// the proxy performs the lookup, which keeps DNS off your machine entirely. We go deeper on the split in what is a SOCKS5 proxy.
SOCKS authentication is the one place the API differs from HTTP proxies. .basic_auth sets an HTTP header, which a SOCKS proxy never sees, so for an authenticated SOCKS5 proxy you put the credentials in the URL userinfo and let the SOCKS handshake carry them:
let client = Client::builder()
.proxy(Proxy::all("socks5h://user:[email protected]:1080")?)
.build()?;
Rotating across a pool
One IP sending every request is the exact shape a rate limiter watches for. Because the proxy is fixed on the Client, the clean way to rotate through one reused client is Proxy::custom, which takes a closure that runs on every request and returns the exit to use:
use reqwest::{Client, Proxy, Url};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let pool: Arc<Vec<Url>> = Arc::new(
[
"http://203.0.113.7:8080",
"http://203.0.113.24:3128",
"http://198.51.100.14:8080",
]
.iter()
.map(|s| s.parse().expect("valid proxy URL"))
.collect(),
);
let next = Arc::new(AtomicUsize::new(0));
let rotating = {
let pool = Arc::clone(&pool);
let next = Arc::clone(&next);
// Called once per request: hand back the next exit, round-robin.
Proxy::custom(move |_url| {
let i = next.fetch_add(1, Ordering::Relaxed) % pool.len();
Some(pool[i].clone())
})
};
let client = Client::builder().proxy(rotating).build()?;
That is one Client, one connection pool, and a different exit on each request. You do not have to hardcode the list either. Our free proxy API returns a fresh pool as plain text you can pull at startup and parse into the Vec<Url>:
let raw = reqwest::get(
"https://hproxy.com/api/proxy-list?format=txt&protocol=http&recent=true&limit=50",
)
.await?
.text()
.await?;
let pool: Vec<Url> = raw
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.filter_map(|l| format!("http://{l}").parse().ok())
.collect();
For production, a rotating gateway that hands you one endpoint and a fresh residential IP per request removes list management entirely, but rotating a real list first is the best way to understand what that gateway does for you.
Verify the exit IP
Never assume a proxy is working. Confirm the target sees the proxy's address and not yours, and do it before you trust an exit with real traffic:
use reqwest::{Client, Proxy};
async fn exit_ip(client: &Client) -> reqwest::Result<String> {
client.get("https://httpbin.org/ip").send().await?.text().await
}
let direct = Client::builder().no_proxy().build()?;
let proxied = Client::builder()
.proxy(Proxy::all("http://203.0.113.7:8080")?)
.build()?;
let real = exit_ip(&direct).await?;
let via_proxy = exit_ip(&proxied).await?;
println!("real: {real}");
println!("proxy: {via_proxy}");
assert_ne!(real, via_proxy, "proxy is not changing your IP");
If the two bodies match, the request never went through the proxy, often because an ALL_PROXY variable was overriding an assumption of "direct," or the wrong Client was reused. It is also worth checking what headers arrive at https://httpbin.org/headers, because a transparent proxy can forward your real address in X-Forwarded-For even while origin looks changed. Our proxy checker runs that whole battery, exit IP, anonymity grade, real geolocation, and latency, in one paste if you would rather not script it.
Where to go from here
Two habits keep Rust proxy code alive unattended: set a .timeout(...) on the builder so a silent proxy cannot leave a future pending forever, and verify every exit before you trust it. For a live pool to test against, the free proxy list re-verifies its entries every few minutes, and the free API hands them to you with no key.
From here, the Go guide is the closest sibling in spirit, another compiled language where the proxy rides on the client rather than the request, the cURL guide is the fastest way to sanity-check a proxy from the shell before you wire it into Rust, and proxies for web scraping covers choosing the right proxy type and the request hygiene no HTTP client can add for you. When a project graduates from experiments to production, get IPs nobody else is burning on our paid pools.
Sources
- reqwest::Proxy API (docs.rs):
Proxy::http,Proxy::https,Proxy::all,Proxy::custom, and.basic_authsetting theProxy-Authorizationheader, plus the note that thesocksfeature enables SOCKS proxy URLs. - reqwest crate documentation (docs.rs): the
socksoptional feature and the default reading ofHTTP_PROXY/HTTPS_PROXY/ALL_PROXYsystem proxy environment variables, overridable withClientBuilder::no_proxy. - reqwest source: proxy.rs: the
socks4,socks4a,socks5, andsocks5hschemes matched by the SOCKS connector (reqwest 0.13.4).