- A residential exit is a route, not a clearance: network.residential: true means nothing fired, not that nothing is wrong.
- The pool rotates the address every attempt and never the machine; key counters and history on device.visitor_id, not the IP.
- An exit already tied to a pool returns network.proxy: true and blocks by default; on the rest, the device and the clock carry the signal.
- Read timezone.matches_ip only when timezone.checked is true, or every VPN customer becomes a false positive.
- Route on device.antidetect and antidetect_browser; the tampering score read 0.0029 on spoofed and unspoofed profiles alike on our bench.
On this page
A residential proxy defeats an IP check by construction. The address belongs to a real household, the ISP is one your customers use, and no reputation database has an opinion on it because the pool rotates the address before complaints accumulate. Behind that address the operator runs the same machine, the same browser profile and the same script for every attempt. This playbook is about detecting the machine once the address has stopped identifying anyone: how the attack is assembled, why reputation returns green, which fields in a /v1/evaluate verdict still move, and what each rung of the response needs before it fires. It is the hub for the residential-proxy posts in our fraud playbooks.
How a residential proxy attack is run
The stack has four parts, none hard to buy; we name tools and list prices because a team that has never priced them overestimates the effort on the other side.
- Rent a pool. Residential bandwidth is sold by the gigabyte with geographic targeting included. On 2026-08-29 Oxylabs listed $6/GB on a 5 GB starter plan down to $2.50/GB at 1 TB; Decodo listed $4/GB pay as you go and a 3-day trial of 100 MB; IPRoyal listed $7.00/GB falling to $1.75/GB in bulk, with traffic that does not expire.
- Pin the exit for as long as the action takes. Sticky sessions hold one address across a multi-page checkout or a signup with email verification: up to 24 hours at Oxylabs, up to 7 days at IPRoyal (both checked 2026-08-29). Per-request rotation is the other mode, for spreading attempts thin.
- Put a browser profile in front of it. One antidetect profile per account (Kameleo, GoLogin, Multilogin, AdsPower, Dolphin Anty) with its own cookie jar and spoofed fingerprint, or an automation framework with stealth patches; which still trips a verdict is the subject of our antidetect browser hub.
- Run the action and rotate. Signup, checkout, bonus claim, login attempt; any CAPTCHA goes to a solver farm at the prices in our CAPTCHA farm economics post; then the exit changes and the next attempt begins.
It enters your stack at the endpoint your customers use, from a consumer ASN, with a current Chrome user agent and a normal TLS handshake. Nothing in the request says proxy.
Where the pools come from, by class
Sourcing decides how an exit behaves under observation; the supply chain in full is in what a residential proxy is and where the addresses come from.
| Class | How it is sourced | What it looks like on the wire |
|---|---|---|
| SDK-monetized peer pool | An SDK inside a free app routes traffic through the device. Pawns pays the app developer a fixed $0.20 per GB shared through users' unused IPs; PacketStream pays the person sharing $0.10 per GB; Honeygain describes it as sharing "unused internet bandwidth" (all checked 2026-08-29). | Consumer ASN, a household or phone line, the household's own traffic beside it. Exits come and go with the app; per-request rotation is cheap. |
| ISP / static residential | "An IP address hosted on a data center but registered under ISPs", as Oxylabs describes its ISP proxies, naming British Telecom, Comcast, Lumen and Frontier, at $1.20 to $1.60 per IP per month (checked 2026-08-29). | Residential in every registry, datacenter stability: the same exit for weeks, no household traffic. Defeats "residential means human" and "stable means human" at once. |
| Botnet-derived | Malware bundled with free VPN installers. The 911 S5 network held "over 19 million compromised IP addresses in over 190 countries" and spread through MaskVPN, DewVPN, PaladinVPN, ProxyGate, ShieldVPN and ShineVPN, per the FBI's 2024-05-29 alert. | Indistinguishable from a peer pool at the network layer. The owner never consented; the exit vanishes when the machine sleeps. |
| Mobile / CGNAT | Carrier egress, sold by the gigabyte; IPRoyal listed rotating mobile at $6.80/GB on 2 GB, falling to $5.20/GB at 100 GB (checked 2026-09-01). Carriers put many subscribers behind one public address, which is what RFC 6598 reserved 100.64.0.0/10 for in 2012. | One address is thousands of real phones. A rule keyed on the IP is a rule against a city, and rotation follows the carrier's schedule. |
Why IP reputation misses residential proxies
Reputation data is real and useful, and structurally behind. Addresses are cycled to dilute complaints and spread velocity thin; by the time a database has enough reports to flag one, it has been rested and another household carries the traffic. Our own classification ties exits to named pools when it can; the operator's business is making sure most exits are not there yet.
The second failure is a misreading: network.residential: true means neither network.datacenter nor network.proxy fired — the exit looks like consumer access, which is what the pool pays for. It describes the route, not the person. The third is the reflex fix: blocking residential ASNs blocks consumer internet, and a stale denylist entry blocks a household that left the pool weeks ago; why datacenter ranges can be handled decisively and residential ones cannot is in residential versus datacenter proxies.
| Connection | network.* in the verdict | What the IP layer can and cannot do |
|---|---|---|
| Commercial VPN exit | vpn: true, often datacenter: true, service names the provider when known (PROTON_VPN in the reference sample) | Shared, stable exits; classification works. The question is policy, not detection. |
| Cloud / datacenter | datacenter: true, service names the cloud when known | ASN classification is decisive. The false positives are corporate egress and self-hosted VPNs. |
| Residential proxy exit, classified | proxy: true, residential: false, service names the pool when known (BRIGHT_DATA in the sandbox) | Caught at the network layer. Only holds for exits already tied to a pool. |
| Residential proxy exit, unclassified | proxy: false, residential: true, service is the ISP | Nothing fires. The device, the browser's clock and your own counters carry the signal. |
| ISP / static residential | Either of the two rows above, depending on whether the address has been tied to a pool | Reads as a home line with unusual stability; stability heuristics point the wrong way. |
| Mobile / CGNAT | residential: true, service is the carrier | Per-IP counters measure a city. Only device-keyed counters mean anything. |
| Home line | residential: true, service is the ISP (DEUTSCHE_TELEKOM in the reference sample) | The case every row above is imitating. |
Test whether your stack sees it
An afternoon, nothing beyond a trial, against your own site and accounts; read the trial's terms first.
- Take a trial from any pool above; Decodo offered 3 days and 100 MB on 2026-08-29, more than a signup flow needs.
- Route an ordinary browser through an exit in your main market and complete the action that matters: signup, checkout, bonus claim.
- Open whatever records the session on your side; no signal means the IP layer was your only control, and it just failed.
- Repeat with the same browser and a different exit. Does anything on your side link the two sessions? That link is the rest of this post.
Run your own signup or checkout traffic through this: the free scanner returns the same verdict the API does.
Open the scannerThe tells that survive rotation
Three things do not rotate with the address: the machine, the story the browser tells about itself, and the operator's rate. Each row is one measurement, the field it appears in, and the weight the evidence justifies on its own; the ladder below is how they combine. The first row is the network layer's whole contribution — exits already tied to a pool, named in network.service when known — and the rest is what still moves when that row is silent.
| Tell | Verdict field / reason code | Weight |
|---|---|---|
| Exit already tied to a pool | network.proxy: true, network.service, reason proxy_detected | Block (the engine's default) |
| Browser misrepresents its environment | device.antidetect: true, reason antidetect_browser | Block |
| Automated browser | device.automation: true, reason automation_detected | Block |
| One device, many accounts | device.linked_accounts, device.multi_account: true, reason multi_account_device | Step-up; block with any row above |
| Browser clock disagrees with the exit country on an ordinary connection | timezone.matches_ip: false while timezone.checked: true, reason timezone_mismatch | Step-up |
| Home-line exit, first-seen device, high-value action | network.residential: true with device.times_seen: 1 or a recent device.first_seen | Step-up |
| Attempts per device per hour | Your own counter keyed on device.visitor_id | Step-up at your threshold; block with a row above |
| Device identified unusually often | device.high_activity: true, reason high_activity_device | Evidence |
| Same exit, a different device each time | Your counters keyed on IP versus on visitor_id | Evidence only; CGNAT produces the same shape |
Device continuity: the test and the limit
device.visitor_id is derived from the browser and machine, not the connection, and holds across cookie clears and private windows. In our harness run of 2026-08-12 (WKWebView profiles against the homepage scanner's keyless /api/device-intel path, which reports the same identity as visitorId) a profile kept one id across three runs with the storage jar wiped each time, timesSeen counting 1, 2, 3; two profiles with different screen sizes collapsed into one id; patching canvas and WebGL readback inside the engine did not move it. One stack behind a thousand exits is still a handful of devices; device.linked_accounts — the distinct accounts a device has touched under your key — is the multi-accounting signal, and the SQL is in our multi-accounting playbook.
The limit, from the same run: early on, each spoofed profile minted its own stable id; after some thirty sightings from one machine, no JS-level change minted a new one at all. What fired on every spoofed profile and never on the baseline was the tampering flag one layer up: continuity catches the operator who rotates exits, integrity the one who also rotates profiles — the argument in device fingerprinting versus IP intelligence.
The browser's clock against the exit: the test and the trap
Send the visitor's IANA timezone as tz (the client SDK sends it as sentinel_tz; on your own, Intl.DateTimeFormat().resolvedOptions().timeZone, no permission prompt) and the verdict adds a timezone block: reported, country, matches_ip, checked. A home-line exit whose browser clock belongs to another country is the shape of a residential proxy the network layer missed; the engine adds timezone_mismatch and escalates allow to review, never to block.
The limit: an operator who sets the profile's timezone to the exit country passes by construction; our harness grew a --tz= flag for that after a third-party checker caught a profile claiming America/New_York behind an Estonian exit our device layer had passed. It catches the careless profile; travelers land here legitimately, so it is a step-up, never a block.
Velocity on the device: the test and the limit
Count signups, checkouts and login attempts per visitor_id and per account, never per IP; the address is the measurement the pool exists to defeat. The test: run the self-test twice through different exits and confirm the counter reads 2. The limit: once a device is counted, the operator's next move is a different machine, a virtual machine or an emulator, which carry their own reason codes (virtual_machine, emulator_detected).
Browser integrity: route on the flag, not the score
In the same run the scanner's browserTampering flag, which device.antidetect and the antidetect_browser reason are built from, fired on all three spoofed profiles and never on the baseline. tamperingScore (device.tampering_score in a verdict) read 0.0029 on every profile, spoofed and unspoofed alike. Route on the boolean and its reason code; do not build a threshold on the score until it discriminates. The same pattern on a commercial tool is in our Kameleo and Undetectable live test. The limit, also measured: canvas noise alone passed the flag, so it is a plausibility check on values it can contradict, not hook detection.
Where the check goes
Server-side, after the form and its client token arrive, before the action commits. Three optional fields separate a network-only verdict from the one this post is about: fingerprintEventId (no device block without it), accountId (no linked_accounts without it) and tz. Ship it in shadow mode: log the verdict, change nothing, read the reasons for a week, then wire the decision. Placement per framework, timeouts and the fail-open path are in our Express middleware guide.
// POST /signup — after the form arrives, before the account exists.
const SIGNUPS_PER_DEVICE_PER_HOUR = 3; // your number, from your own logs
app.post('/signup', async (req, res) => {
const pendingId = newUserId(); // the id you are about to create
const r = await fetch('https://maskbreak.com/v1/evaluate', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_test_YOUR_KEY', // sk_test_ while you shadow
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: req.body.monocle, // hidden input the client SDK fills
fingerprintEventId: req.body.sentinel_fp, // no device block without it
accountId: pendingId, // hashed server-side; unlocks linked_accounts
email: req.body.email, // adds email.disposable
tz: req.body.sentinel_tz // adds the timezone block
})
});
const v = await r.json();
// The address is the one field the operator controls cheaply.
// Everything worth counting hangs off the device instead.
const id = v.device?.visitor_id;
if (id) {
await db.query(
`INSERT INTO device_events (visitor_id, account_id, action,
decision, risk_score, reasons, seen_at)
VALUES ($1, $2, $3, $4, $5, $6, now())`,
[id, pendingId, 'signup', v.decision, v.risk_score, v.reasons]
);
}
const perHour = id ? await db.countEvents(id, 'signup', '1 hour') : 0;
if (v.decision === 'block' || perHour > SIGNUPS_PER_DEVICE_PER_HOUR) {
return res.status(403).json({ error: 'Signup unavailable.' });
}
const clockDisagrees = v.timezone?.checked && v.timezone.matches_ip === false;
if (v.decision === 'review' || clockDisagrees || v.device?.times_seen === 1) {
return res.status(202).json({ next: 'verify_email' }); // step-up, not a wall
}
// allow: create the account
});
With that table in place the questions are ordinary SQL: accounts per device today, devices per account, how many of either in the last hour. Persist reasons and risk_score on every row; weeks later they tell you which combination predicted fraud and which predicted a family.
To exercise the block path without a browser, send the deterministic token through the public sandbox key; nothing is billed or stored and the response carries "test": true.
curl -s -X POST https://maskbreak.com/v1/evaluate \
-H "Authorization: Bearer sk_test_sandbox" \
-H "Content-Type: application/json" \
-d '{"token": "test_proxy"}'
That is abridged from the sandbox response of 2026-08-29 — every value shown is as returned. test_clean returns the opposite shape: decision: allow, residential: true, empty reasons. Neither carries a device block, because a test token has no browser behind it.
Response ladder
Residential proxy detection is a correlation problem, and correlation produces confidence, not certainty. The response carries that shape: three rungs, each with the evidence it needs, and nothing jumping a rung on a single soft signal.
| Rung | Evidence it needs | What you do |
|---|---|---|
| Allow | decision: allow and nothing in your own counters. A residential exit on its own is here, and so is times_seen: 1 on a low-value action: every new customer is a first-seen device. | Proceed. Log the verdict and the device id anyway. |
| Review / step-up | Mixed evidence: decision: review (the engine escalates on timezone_mismatch and disposable_email), a multi_account_device reason (the engine adds the code without escalating; stepping up on it is our call), a home-line exit plus a first-seen device plus a high-value action, or your device counter over its threshold. | Verify something the pool cannot supply: email confirmation, a payment-method check, a hold on the payout, a manual queue for the bonus. |
| Block | Independent measurements agreeing: decision: block (an exit tied to a pool, an antidetect or automated browser, Tor), or two step-up rows from different layers, such as a device linked to a dozen accounts and a counter tripping in the same hour. | Refuse, with the same response the rest of your failures use, so the operator learns nothing about which tell fired. |
An exit tied to a pool is a hard block by default; if corporate egress in your traffic is named as a proxy, a dashboard rule can move that signal to review for your key — the response then reports decision_source: "rules" with the engine's call kept in engine_decision. Route on decision, sort on risk_score: the score orders a review queue, not a second decision (our post on risk score thresholds).
What it costs you
The operator pays per attempt, by the gigabyte, at the list prices above; you pay per miss with the bonus, trial, chargeback or referral reward, and per false positive with a customer. That asymmetry is why the attack exists.
Worked model. Inputs stated, nothing measured: assume a signup flow moves 300 KB through the proxy, page, assets and API calls included. At Decodo's pay-as-you-go $4/GB (checked 2026-08-29) that is $0.0012 of bandwidth per attempt; at PacketStream's $1.00/GB (checked 2026-08-29) it is $0.0003, so a thousand attempts cost between thirty cents and $1.20 in bandwidth, plus the solver fee for any CAPTCHA, usually the larger line. A sticky ISP address for a month is $1.20 to $1.60 at Oxylabs (checked 2026-08-29). While the reward per fake account exceeds the cost per attempt, the attack pays — until the device is counted, the one cost the operator cannot rent by the gigabyte. The product-side view is on our multi-accounting page; the network-side capability is on the proxy detection page.
Checklist
- Counters and history keyed on
device.visitor_id, not the IP; a per-IP rule is a rule against a CGNAT city. fingerprintEventId,accountIdandtzon every evaluate, or the device block,linked_accountsand the timezone block never appear.network.residentialread as a route, never a clearance;timezone.matches_ipread only whentimezone.checkedis true.- Routing on
device.antidetectandantidetect_browser; no threshold ontampering_score. - Step-up on mixed evidence; block when independent layers agree or the engine already says
block. reasons,risk_scoreanddecisionpersisted on every event, shadow mode before enforcement.- No blanket rule on residential ASNs, no denylist you cannot expire; the self-test once a quarter through a different pool.
Questions people ask
- Can you detect a residential proxy from the IP address alone?
- Only when the exit has already been tied to a pool, in which case the verdict returns network.proxy: true and names the provider in network.service. The pool's business is rotating addresses faster than that classification catches up, so most exits arrive as network.residential: true. Detection then rests on the device (visitor_id, linked_accounts), the browser's clock against the exit country, and your own counters keyed on the device.
- Does network.residential: true mean the visitor is safe?
- No. It means neither network.datacenter nor network.proxy fired, so the exit looks like consumer access. That is what a residential proxy pays for. Read it as a description of the route and let the device block, the timezone block and your velocity counters decide; the response ladder in this post starts a home-line exit at allow and moves it up only on independent evidence.
- How do I detect rotating residential proxies?
- Stop keying anything on the IP. Send fingerprintEventId, accountId and tz with every /v1/evaluate call, store visitor_id, decision, risk_score and reasons in a device_events table, and count attempts per device per hour. A pool gives each attempt a fresh address; it does not give each attempt a fresh machine, and device.linked_accounts shows how many accounts that machine has touched under your key.
- Should I block residential proxies?
- Block what the engine already blocks: an exit tied to a pool, an antidetect or automated browser, Tor. Everything else is graduated. A residential exit alone is allow; a home-line exit plus a first-seen device plus a high-value action is a step-up; two step-up tells from different layers in the same hour is a block. Blocking residential ASNs blocks your own customers, and a per-IP rule on a CGNAT address blocks a city.
- What is the difference between a residential proxy and an ISP proxy?
- A residential (peer) proxy exits through a real household or phone whose bandwidth is shared via an SDK, so exits appear and vanish and rotate cheaply. An ISP or static residential proxy uses an address registered to an ISP but hosted on a datacenter server, rented per IP per month, so it holds the same exit for weeks with no household traffic beside it. The first defeats reputation; the second defeats stability heuristics as well.
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.