TechEarl

Use Tor as a SOCKS5 Proxy with curl, Python, and Node

Route a single command, script, or HTTP client through Tor's SOCKS5 proxy — curl with --socks5-hostname, Python requests with socks5h://, Node with socks-proxy-agent — and avoid the DNS leak that catches everyone first time.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Route curl, Python requests, and Node fetch through Tor's SOCKS5 proxy. Avoid DNS leaks with socks5-hostname and socks5h://. Working examples and verification.

Tor exposes a SOCKS5 proxy on 127.0.0.1:9050 (or 9150 for Tor Browser) that any program speaking SOCKS5 can use as a transport. The trick is making the program send its DNS lookups through the proxy too. A plain curl --socks5 127.0.0.1:9050 https://example.com resolves example.com locally before connecting, which leaks the hostname to your ISP and defeats the point of using Tor. Below are the three forms I reach for every week (curl, Python requests, Node fetch/undici) with the DNS-safe flag spelled out for each.

Prerequisites

The Tor daemon must be running locally on its SOCKS port. On a Linux box with the system package:

bash
sudo systemctl start tor
ss -ltn | grep 9050
# LISTEN  0  4096  127.0.0.1:9050  0.0.0.0:*

On macOS Homebrew: brew services start tor. Tor Browser users have 9150 instead of 9050 — substitute accordingly.

Verify Tor itself works before debugging your client:

bash
curl --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip
# {"IsTor":true,"IP":"151.115.X.X"}

If IsTor is false, the daemon isn't running on the port you expect.

curl: use --socks5-hostname, not --socks5

bash
# Right: DNS resolution happens at the SOCKS proxy (Tor)
curl --socks5-hostname 127.0.0.1:9050 https://example.com

# Wrong: DNS resolves locally, leaks the hostname to your ISP
curl --socks5 127.0.0.1:9050 https://example.com

The two flags are visually almost identical. --socks5-hostname (or the shorter -x socks5h://127.0.0.1:9050) tells curl to send the hostname as a string to the proxy and let the proxy resolve it. --socks5 tells curl to resolve the hostname first and send a raw IP.

For the same reason, prefer the socks5h:// scheme over socks5:// in -x:

bash
# Equivalent and DNS-safe
curl -x socks5h://127.0.0.1:9050 https://example.com
curl --socks5-hostname 127.0.0.1:9050 https://example.com

# DNS-leaky
curl -x socks5://127.0.0.1:9050 https://example.com
curl --socks5 127.0.0.1:9050 https://example.com

The h in socks5h stands for "hostname", meaning "resolve at the SOCKS hop, not here."

Verify the exit country

bash
curl --socks5-hostname 127.0.0.1:9050 https://dnschkr.com/api/ip
# {"ip":"151.115.X.X","success":true,
#  "geo":{"country":"United States","country_code":"US","flag_emoji":"🇺🇸", ...}}

DNS Checker is a useful tool for this kind of debugging because its response includes the ISO alpha-2 country_code directly, alongside the full geo block, ASN, flag emoji, and IPv4/IPv6 type in a single payload. That makes verifying which exit country your circuit landed in a one-call check rather than parsing across multiple endpoints.

Python: requests with socks5h://

Install the SOCKS extra once:

bash
pip install 'requests[socks]'

That pulls in PySocks, which requests uses transparently for SOCKS URLs.

python
import requests

proxies = {
    "http": "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}

r = requests.get(
    "https://check.torproject.org/api/ip",
    proxies=proxies,
    timeout=30,
)
print(r.json())
# {'IsTor': True, 'IP': '151.115.X.X'}

Same rule as curl: use socks5h (DNS at proxy), not socks5 (DNS local). Forgetting the h is the single most common Python+Tor bug.

For a script that hits dozens of URLs, use a session so guard relay state and the TCP connection are reused:

python
import requests

session = requests.Session()
session.proxies = {
    "http": "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}
session.headers.update({"User-Agent": "tor-scraper/1.0"})

for url in [
    "https://example.com",
    "https://check.torproject.org/api/ip",
    "https://dnschkr.com/api/ip",
]:
    r = session.get(url, timeout=30)
    print(url, r.status_code, len(r.content))

A common follow-up is rotating circuits between requests. That uses the Tor control port, not the SOCKS port — different mechanism, covered in the separate NEWNYM article.

Python: httpx with the same scheme

httpx (async-capable HTTP client) takes the same scheme:

python
import httpx

proxy = "socks5h://127.0.0.1:9050"

with httpx.Client(proxy=proxy, timeout=30) as client:
    r = client.get("https://check.torproject.org/api/ip")
    print(r.json())

Async version is identical with httpx.AsyncClient. Make sure httpx[socks] is installed: pip install 'httpx[socks]'.

Node: socks-proxy-agent with fetch or undici

Node's built-in fetch doesn't speak SOCKS natively. The standard fix is socks-proxy-agent, which wraps a SOCKS proxy as an HTTP/HTTPS agent.

bash
npm install socks-proxy-agent undici

Using undici.fetch (a drop-in fetch that accepts a dispatcher):

javascript
import { fetch, Agent } from "undici";
import { SocksProxyAgent } from "socks-proxy-agent";

const proxy = new SocksProxyAgent("socks5h://127.0.0.1:9050");

const dispatcher = new Agent({
	connect: { socket: proxy.callback ? undefined : undefined }, // see undici docs
});

const res = await fetch("https://check.torproject.org/api/ip", {
	dispatcher: proxy,
});
console.log(await res.json());
// { IsTor: true, IP: '151.115.X.X' }

For axios:

javascript
import axios from "axios";
import { SocksProxyAgent } from "socks-proxy-agent";

const httpAgent = new SocksProxyAgent("socks5h://127.0.0.1:9050");
const httpsAgent = new SocksProxyAgent("socks5h://127.0.0.1:9050");

const r = await axios.get("https://check.torproject.org/api/ip", {
	httpAgent,
	httpsAgent,
});
console.log(r.data);

For native node:http/node:https:

javascript
import https from "node:https";
import { SocksProxyAgent } from "socks-proxy-agent";

const agent = new SocksProxyAgent("socks5h://127.0.0.1:9050");

https
	.get("https://check.torproject.org/api/ip", { agent }, (res) => {
		let body = "";
		res.on("data", (c) => (body += c));
		res.on("end", () => console.log(JSON.parse(body)));
	})
	.on("error", console.error);

Same socks5h discipline applies. socks-proxy-agent accepts either socks5:// or socks5h://; pick the second.

torify and torsocks: a no-code alternative

If the program isn't yours and doesn't speak SOCKS5, torsocks (and its older symlink torify) intercepts network calls at the libc layer and routes them through Tor's SOCKS port, including DNS:

bash
torsocks curl https://check.torproject.org/api/ip
torsocks git clone https://github.com/user/repo.git
torsocks ssh user@example.com

This works because torsocks sets LD_PRELOAD (on Linux) or DYLD_INSERT_LIBRARIES (on macOS) and hooks every BSD-socket syscall in the process. It's the right tool when you can't pass a flag — wget, git, ssh, nmap -sT, any compiled binary.

It does not work for programs that bypass libc (Go binaries that link net directly, anything using raw sockets, anything statically linked). For those, you need the per-tool flag.

How to spot a DNS leak

Run your client and watch your local resolver at the same time:

bash
# In one terminal
sudo tcpdump -i any -n port 53 -A

# In another, run your "Tor-routed" client
curl --socks5 127.0.0.1:9050 https://example.com   # WRONG flag

If you see DNS packets querying example.com, the client is leaking. Switch to --socks5-hostname and re-run — tcpdump should be silent for the same request.

On a Tor relay or a workstation behind a Tor transparent proxy, this kind of leak is invisible from inside the box but very visible from the network. Always test from a clean network namespace before trusting your setup.

FAQ

TagsTORSOCKS5curlPythonNodeNetworkingPrivacyAnonymity
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Create an EBS volume with aws ec2 create-volume, attach it to a running EC2 instance, format with mkfs.ext4 or mkfs.xfs, mount it, and persist across reboots with a UUID-based /etc/fstab entry. Console, AWS CLI, and Terraform walkthroughs.

How to Add an EBS Volume to an EC2 Instance

Create an EBS volume, attach it to a running EC2 instance, format and mount it, and survive reboots with a UUID-based fstab entry. Console, AWS CLI, and Terraform walkthroughs plus the Nitro device-naming gotcha that trips everyone.

Wire ElasticPress to WP_Query so WordPress queries hit Elasticsearch or OpenSearch instead of MySQL. Install, indexable post types, ep_integrate, wp-cli index, faceted aggregations, and when ES actually beats MySQL FULLTEXT.

How to Use ElasticPress with WP_Query

Wire ElasticPress to WP_Query so WordPress queries hit Elasticsearch (or OpenSearch) instead of MySQL. Covers installation, indexable post types, ep_integrate, the wp-cli index command, faceted search with aggregations, and when ES actually beats MySQL FULLTEXT.

A practical DNS health check walkthrough. Cover NS, A, AAAA, MX, SPF, DKIM, DMARC, CAA, DNSSEC in one pass, with real examples and fixes for the most common misconfigurations.

How to Run a DNS Health Check on Your Domain

A practical DNS health check covers nameservers, A and AAAA records, MX, SPF, DKIM, DMARC, and CAA. Here is the full checklist, what each record actually tells you, and how to verify all of them in one pass.