Resources Docs Free Blog Contact
Log in Get started
SIG-114 · MASKBREAK RESEARCH
Fraud playbooks

Tor Exit Node Detection in Real Time

Match the address your edge saw against the Tor Project's observed exit list, refreshed hourly, and step up on account routes instead of blocking reads.

In short
  • Match observed exit addresses, not relay counts: 3,312 ExitNode records resolved to 1,427 addresses in our 2026-08-29 snapshot.
  • A daily copy is stale by design: the file's observation timestamps spanned 40 hours; refresh hourly and never replace with an empty fetch.
  • Route on decision and reasons: a Tor-only hit returns decision: block with risk_score 15 and isSuspicious false.
  • network.residential reads true on a Tor exit; it describes the route, never a clearance.
  • A missing Sec-CH-UA, an ESR TLS hash and a 200 by 100 window bucket are Firefox tells, not Tor tells.
On this page
  1. How Tor traffic reaches your stack
  2. Why a daily copy of the exit list misses it
  3. The tells
  4. Where the check goes
  5. Response ladder
  6. What it costs you
  7. Checklist

Tor is the one anonymizing network that publishes its own address list. The Tor Project's bulk exit list is a plain text file, one IPv4 address per line, so the check looks like a set lookup, and most teams ship that: a cron job, a daily copy, a firewall rule. That version misses exits that came up after the fetch, keeps ones that went away, counts relays where it should count addresses, and blocks the readers the Tor Project's own abuse FAQ says to sort at the application layer. This playbook is the check we run instead: the published list plus our own network intelligence, refreshed on a clock, read at the route where the decision is made.

How Tor traffic reaches your stack

Tor is not a fraud tool, but it is a free way to make every request arrive from an address that is not yours; each step is in the tor manual (checked 2026-08-29).

  1. Run a client. Tor Browser for manual account work; the tor daemon for scripts, a SOCKS proxy on port 9050 by default that curl --socks5-hostname, Python requests and Playwright's proxy option all speak.
  2. Pin the exit country. ExitNodes {us} in torrc restricts exits to one country; StrictNodes 1 stops Tor falling back to others, so stolen US credentials arrive from a US exit and trip no geography rule.
  3. Rotate. A circuit is reused for ten minutes by default (MaxCircuitDirtiness); the controller signal NEWNYM switches to clean circuits on demand, and the control spec says Tor may rate-limit it. Every rotation is a new exit address for the same operator.
  4. Enter through the routes that matter. The request reaches your edge from the exit relay's address, on the same routes as any account takeover run: login, password reset, signup, payout.
bash
# torrc: US exits only, fresh circuit after 60 s
ExitNodes {us}
StrictNodes 1
MaxCircuitDirtiness 60
SocksPort 9050

# the request your login route receives
curl --socks5-hostname 127.0.0.1:9050 https://example.com/login \
  -d 'email=victim@example.com&password=Summer2026!'

The destination sees the exit relay, never the client, and abuse complaints go to the relay operator (types of relays, checked 2026-08-29). You cannot push the cost upstream, only decide what an exit address may do on your site.

Why a daily copy of the exit list misses it

The Tor Project also publishes exit-addresses, the file the bulk list is built from: one record per exit relay, each with timestamped ExitAddress lines naming the address its scanner saw traffic leave from. We fetched both files at 18:59 UTC on 2026-08-29 and counted them with wc and awk. The observation timestamps ran from 02:34 UTC the previous day to five minutes before the fetch: a copy taken yesterday morning misses every address confirmed since and still carries some the scanner has not seen for a day.

Relays are not addresses. The same fetch held 3,312 ExitNode records but 1,427 distinct addresses, and the bulk list was those 1,427 exactly. Tor Metrics counted 3,272 relays with the Exit flag on 2026-08-27 (relay flags, CSV checked 2026-08-29). More than two exit relays share an address on average, so a relay count overstates what you have to match. One fingerprint had three ExitAddress lines, three distinct addresses; the format allows it, so match the observed-address file.

Not every listed exit can reach you. Each relay carries an exit policy, and reject *:* relays only inside the network (exit policies, checked 2026-08-30); since March 2020 the list no longer filters by policy (exit list service change), so the raw count overstates your exposure. And check the address your edge saw, never a forwarded header, or anyone can write a Tor verdict for someone else's address.

Bridges do not change this. A bridge is a "non-public Tor relay" kept out of the directory so ISPs and governments cannot block them all (what is a bridge), and obfs4 is "a pluggable transport that makes Tor traffic look random" (Tor glossary; both checked 2026-08-30). Both hide the first hop from the client's own network; the traffic still leaves through a listed exit relay. An earlier version of this post said bridge users bypass exit lists. They do not: what is undetectable by design is the bridge's address, which never reaches you.

Two last gaps. The list we fetched held no IPv6 addresses, so a v6 exit is a miss for the list alone. And a firewall rule blocks reading as well as writing, which the Tor Project's abuse FAQ argues against, recommending "application-level decisions to separate the legitimate users from the jerks" and noting that hundreds of thousands of people use Tor every day for ordinary data hygiene (banning Tor, checked 2026-08-30).

Try it

Run your own signup or checkout traffic through this: the free scanner returns the same verdict the API does.

Open the scanner

The tells

Our verdict resolves Tor from two inputs: the Tor Project's published exit list, refreshed every four hours and never replaced with an empty fetch, and our own network intelligence, which classifies the connection independently of the list's IPv4-only format. Either sets network.tor and adds tor_exit_node to reasons.

TellVerdict field / reason codeWeight
Address on the current exit list, or classified as Tor by our network layernetwork.tor: true, network.service: "TOR", tor_exit_nodeStep-up on account routes; block on cash-out
Anonymizing layer with no named VPN or proxynetwork.anonymous: true, anonymous_networkEvidence
Tor exit plus a device never seen under your keytor_exit_node with device.times_seen: 1Step-up
Tor exit plus a device already linked to other accountsdevice.linked_accounts, multi_account_deviceBlock on signup, referral and bonus routes
Tor exit plus a spoofed or automated browserantidetect_browser, automation_detectedBlock on any route
Resist-fingerprinting settings in the browserdevice.privacy_mode: trueEvidence only; we have not run Tor Browser on our bench
Browser clock disagrees with the exit countrytimezone.checked: falseNone; expected on Tor and not raised
Exit reads as a home linenetwork.residential: trueNone; means neither datacenter nor proxy, so a Tor exit reads residential

The last row is the trap: network.residential describes the route, not the risk, and a rule that treats residential as safe waves Tor through (the residential proxy guide says the same of proxy exits on consumer lines).

Three things that are not Tor tells

An earlier version of this post listed browser-side tells that do not hold. Its header-ordering and session-shape tells are gone, unsourced and unmeasured; these three are corrected.

A request without Sec-CH-UA is a Firefox or Safari request, not a Tor request. Client hints are a Chromium feature: MDN's compatibility data lists Chrome 89 and marks Firefox and Safari as not supported (Sec-CH-UA, checked 2026-08-30). Tor Browser inherits the absence from Firefox.

Tor Browser's TLS handshake is Firefox ESR's. Tor Browser 15.0 is "our first stable release based on Firefox ESR 140" (release notes, 2025-10-28). A JA4 hash that matches Firefox ESR tells you the engine; we have not measured a difference between Tor Browser and ESR on our bench and do not treat an ESR hash as a Tor signal (see the JA4 explainer).

The window is rounded to multiples of 200 by 100 pixels, not 1000 by 1000. Tor Browser "starts with a content window rounded to a multiple of 200px x 100px" and buckets the operating system to Windows 10, OS X 10.15 or "Linux running X11" (fingerprinting protections, checked 2026-08-30). That fits Tor Browser and Firefox with resist-fingerprinting on; on its own it is evidence for device.privacy_mode, nothing more.

Where the check goes

Server side, on the address your edge attributes to the client, in the route handler, before the side effect. GET /v1/lookup/{ip} covers log enrichment and allowlist screening on a bare address; POST /v1/evaluate with the SDK token covers live visits, plus the device layer when you forward fingerprintEventId; both request shapes are in the API reference. In the browser, Sentinel.collect() returns that token and event id — the SDK keeps Sentinel, the product's original name, as its global. The decision point is one branch on reasons, per route class.

javascript
// Express: one evaluation, three route classes.
// Read-only routes never call this; cash-out routes always do.
const CASH_OUT = new Set(['/payout', '/withdraw', '/cards/add', '/kyc']);

async function screen(req, res, next) {
  const r = await fetch('https://maskbreak.com/v1/evaluate', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer sk_test_YOUR_KEY',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      token: req.body.token,                       // from Sentinel.collect()
      fingerprintEventId: req.body.fingerprintEventId,
      accountId: req.user ? req.user.id : undefined
    })
  }).then(r => r.json());

  const tor = r.reasons.includes('tor_exit_node');
  const device = r.reasons.some(x =>
    x === 'antidetect_browser' || x === 'automation_detected' || x === 'multi_account_device');

  if (tor && (CASH_OUT.has(req.path) || device)) {
    return res.status(403).json({ error: 'action_unavailable' });   // block
  }
  if (tor || r.decision !== 'allow') {
    req.stepUp = true;                                              // review
  }
  req.verdict = r;                                                  // log decision + reasons
  next();
}

The response this branches on, from the public sandbox on 2026-08-30 with sk_test_sandbox and the test_tor token:

decision is block: Tor is a hard signal by default, and the legacy isSuspicious flag stays false on it, so route on decision and reasons, never on that flag. risk_score came back 15 with a block decision, the ordering the thresholds reference describes: the decision is the verdict, the score sorts a review queue. For review on a bare Tor hit, set the tor rule to review in the dashboard Rules tab; a custom rule replaces decision, not reasons.

Keep your own copy of the list

Fetch the list yourself to audit any vendor's Tor verdict, ours included, and to stay covered when a provider is unreachable. This script keeps the previous snapshot and prints what changed, so churn is a number you log.

bash
#!/usr/bin/env bash
# Fetch the Tor exit list, diff it against the last snapshot, keep both.
set -euo pipefail
dir=${TOR_LIST_DIR:-/var/lib/tor-exits}
mkdir -p "$dir"
new="$dir/exits.$(date -u +%Y%m%dT%H%MZ).txt"

curl -fsS --max-time 30 -A 'exit-list-sync/1.0 (ops@example.com)' \
  https://check.torproject.org/torbulkexitlist | sort -u > "$new"

# Never replace data with nothing: an empty fetch is a failed fetch.
[ -s "$new" ] || { rm -f "$new"; echo "empty list, keeping previous" >&2; exit 1; }

if [ -e "$dir/current.txt" ]; then
  added=$(comm -13 "$dir/current.txt" "$new" | wc -l)
  removed=$(comm -23 "$dir/current.txt" "$new" | wc -l)
  echo "$(date -u +%FT%TZ) total=$(wc -l < "$new") added=$added removed=$removed"
fi
ln -sf "$new" "$dir/current.txt"

# Lookup: grep -qxF "$ip" /var/lib/tor-exits/current.txt

Run it hourly, one fetch per fleet, served from your own store. Our feed refreshes every four hours with a random offset of up to ten minutes so a fleet does not hit the Tor Project on the same minute.

Test it

bash
# 1. The deterministic Tor shape, no account needed
curl -s -X POST https://maskbreak.com/v1/evaluate \
  -H 'Authorization: Bearer sk_test_sandbox' \
  -H 'Content-Type: application/json' \
  -d '{"token":"test_tor"}'
# expect: "decision":"block" and "tor_exit_node" in reasons

# 2. A real exit, with your own test key: the first ExitAddress
#    in https://check.torproject.org/exit-addresses right now
ip=$(curl -s https://check.torproject.org/exit-addresses | awk '/^ExitAddress/{print $2; exit}')
curl -s "https://maskbreak.com/v1/lookup/$ip" \
  -H 'Authorization: Bearer sk_test_YOUR_KEY'
# expect: "verdict":"block", "risk_score":90, "signals":{...,"tor":true,...}

On 2026-08-30 the free IP lookup on 171.25.193.25, then the first address in the file, returned verdict: block, risk_score: 90, signals.tor: true. It will have rotated by the time you read this, which is why the script reads the live file.

Response ladder

This is the part most teams get wrong. Tor is used legitimately by journalists, activists, security researchers, abuse-survivors looking for help, and people in countries with surveillance that makes plain-text browsing unsafe. Blanket-blocking Tor at the network layer punishes them. It also visibly signals to attackers that you're a soft target who responded to a single signal.

Better policy patterns:

  • Allow read-only access from Tor. Browsing your marketing site, blog, and docs from Tor should work. There's no fraud risk.
  • Step up auth, don't block, on signup and login. A Tor visitor signing up should clear an extra friction layer (email + phone, or device-bound passkey) rather than be rejected outright. This filters spam without locking out legitimate privacy users.
  • Block Tor on payment, withdrawal, KYC. Tor for cash-out is almost always fraud. Tor for browsing is usually fine.
  • Use Tor as one signal of N, never on its own. Tor + new account + datacenter card BIN + tampered browser fingerprint is a clear block. Tor alone, on a 12-month-old account in good standing, is not.

Mapped onto the verdict:

RungRoutesEvidence requiredWhat the user sees
AllowPages, docs, search, public profilesNone. Do not call the API on read-only routesNothing
ReviewSignup, login, password reset, profile changestor_exit_node alone, or with device.times_seen: 1; no device reason codeA second factor; the same response shape as the clean path
BlockPayout, withdrawal, card add, KYC, referral payouttor_exit_node on a cash-out route; or tor_exit_node with antidetect_browser, automation_detected or multi_account_device on any routeA generic unavailable message, never a Tor-specific one

Keep the review response byte-identical to the clean one until the second factor is issued, so an operator cannot tell the exit was recognized (the credential stuffing playbook shows the constant-shape response). Keep the reason codes with the event: a reviewer a week later needs tor_exit_node next to the timestamp, and ExoneraTor "answers the question whether there was a Tor relay running on a given IP address on a given date".

What it costs you

The cost of over-blocking is the population you turn away. Tor Metrics estimated about 3.3 million daily users on 2026-08-27 (users by country, CSV checked 2026-08-29; Tor Metrics revises the estimate as more relays report, so the exact figure moves between fetches); a firewall rule removes all of them from your reading traffic to stop the fraction that would reach a cash-out route.

The cost of stepping up is the challenge. Twilio Verify lists $0.05 per successful verification plus $0.0083 per SMS in the United States (Verify pricing, checked 2026-08-30; other countries differ). Worked model, inputs stated: one hundred Tor-exit signups a day, each stepped up by one successful US SMS, cost about $5.83 a day at list price, the price of not turning away a signup that might be a journalist.

The cost of abuse lands on you: complaints go to the relay operator (types of relays), so there is no upstream to charge back to, and whatever leaves through a payout from a Tor exit is gone. That is why the rung is a block, and why the account takeover flow puts the evaluation before the password compare, not after the withdrawal.

Checklist

  • Check the address your edge attributes to the client, never a forwarded header.
  • Match against observed exit addresses; keep a dated snapshot so churn is a logged number.
  • Refresh hourly from your own store, keep the previous list on an empty fetch, and pair it with network intelligence for IPv6.
  • Route on decision and reasons; isSuspicious does not move on Tor, and risk_score is a sort key.
  • Never read network.residential: true as a clearance; it is true on a Tor exit.
  • Skip the API on read-only routes, step up on account routes, block on cash-out routes.
  • Keep Firefox tells (a missing Sec-CH-UA, an ESR TLS hash, a 200 by 100 window) out of Tor rules.
  • Log reason codes with the event under the retention you declare (IP addresses are personal data under GDPR), and keep Tor policy separate from VPN detection; the other playbooks cover the attacks Tor is used to run.

FAQ

Questions people ask

Is it legal to detect Tor exit nodes?
The Tor Project publishes the exit list and the exit-addresses file for this use, and identifying that a request came from a listed exit is a lookup on public data. What you do with it is the policy question. An IP address is still personal data under GDPR, so log the reason code under the retention you already declare.
Should I block all Tor traffic?
No. Allow reads, step up on signup, login and password reset, and block on payout, withdrawal and KYC. The Tor Project's own abuse FAQ recommends application-level decisions and multi-tiered access over a network block, and a firewall rule turns away every reader to stop the fraction that would ever reach a cash-out route.
How often does the Tor exit node list change?
Continuously. In our 2026-08-29 snapshot the ExitAddress observation timestamps spanned about 40 hours, from 02:34 UTC the previous day to five minutes before the fetch. A copy from yesterday morning misses every exit confirmed since. Refresh hourly, keep the previous snapshot, and treat an empty fetch as a failure.
Can Tor bridges be detected by IP address?
The bridge cannot: bridges are non-public relays kept out of the directory, and obfs4 disguises the client's connection to them. But a bridge is only the first hop. The traffic still leaves the network through an exit relay, and the exit address is what your server sees and what the published list carries.
What does the Maskbreak API return for a Tor exit?
On a network-only evaluation the sandbox returns decision: block, network.tor: true, network.service: "TOR" and reasons ["tor_exit_node", "anonymous_network"], with risk_score 15 and isSuspicious false. network.residential also reads true, because it means neither datacenter nor proxy. Route on decision and reasons, not on the flag or the score.
Get started

Put the check where the attack enters

One call before signup, login or checkout returns decision, risk_score and the reasons behind them. The free tier is 1,000 requests an hour, no card required. Start with <a href="/vpn-detection">VPN detection</a> and <a href="/proxy-detection">proxy detection</a>, the network layer most attacks lean on.

Get started freeRead the API docs