Proof of Transfer: Billing Signup Abuse by the Gigabyte

Residential proxies killed IP reputation — but they're billed per gigabyte, and that's the crack. Make a client move a large block of unforgeable bytes and you've built a control that costs an honest user seconds and costs an abuser a real invoice, on every attempt. Here's how to enforce it, and everywhere it hurts.

· 18 minute read

My Personal LLM Policy: Extract, not generate

The thinking is mine, whether it’s years old or from this week. What a model does is get it out of my head and onto the page — writing time I’d otherwise never spend, not substance I didn’t have. I read every line, I can defend any sentence, and the errors are mine: the same bar I hold everything here to, model or no model.

TL;DR

Every IP-based defence has quietly stopped working, because a residential proxy will sell you a real consumer IP on a real consumer ISP in whatever country you like. But look at the invoice: that IP is billed per gigabyte, typically $1.75–$12 of it. Meanwhile the honest user’s home connection is flat-rate — bandwidth costs them time, not money. So make the client actually move a large block of bytes it cannot fake, bound to the same network path as the signup, and prove it did. 250 MB is twenty seconds in the background on a 100 Mbps line and $1.25 on a $5/GB proxy. Charge it on every attempt and a 5% survival rate turns that into $25 per working account, against roughly a tenth of a cent for a solved CAPTCHA. It is not bot detection. It’s a price — and unlike a classifier, there’s nothing to solve.

The IP stopped being a signal

For about twenty years, the source IP address was the load-bearing input to every abuse control worth having. Rate-limit per IP. Reputation-score the ASN. Block the datacentre ranges. Flag the ones that just showed up in three countries in an hour. It worked because an IP was genuinely scarce and genuinely tied to a place and a person.

That is over, and it’s worth being precise about why, because the reason determines what replaces it. Residential proxy networks aggregate millions of real consumer connections — sourced mostly from SDKs embedded in free apps and VPNs that pay the device owner, or pay them in ad-free minutes, for resold bandwidth. What comes out the other end is not a spoof or a tunnel you can fingerprint. It is a genuine Comcast subscriber in Ohio, with a genuine Comcast IP, genuine residential rDNS, genuine consumer-grade latency and jitter, and a genuine geolocation.

You cannot block it, because blocking it means blocking Comcast. You cannot reputation-score it, because the reputation belongs to a real household that has done nothing wrong and whose IP will be back in the pool serving a real customer tomorrow. You can’t even rate-limit it usefully: the pool is large enough that no single address is reused often enough to trip anything, and the operator will happily give you a fresh IP per request.

So the entire IP-based layer of the stack degrades to noise, and everything downstream of it — velocity rules, geo-consistency checks, “impossible travel”, per-address quotas — degrades with it. Most teams’ response is to lean harder on the next layer: device fingerprinting, behavioural scoring, challenge flows. Which brings the same problem back one level up, because all of those are classifiers, and a classifier facing an adversary who can query it for free, unlimited times, with an immediate pass/fail signal, always loses eventually. That’s not pessimism; it’s the structure of the game. I’ve written up the general argument as ZFN-53Field Note · currentZFN-53 — Make abuse cost money: attack the unit economics, not the identityAbuse at scale is a business with a P&L. Detection is an arms race you eventually lose, because the attacker gets unlimited free queries against your classifier. Instead find the metered input they can't substitute away from, and inflate it — per attempt, dialled by risk.Open ZFN-53 →.

But look at how they’re billed

Here’s the thing everyone stares straight past. The property that makes a residential proxy undetectable is the same property that makes it expensive, and for the same reason: there is a real person’s real metered connection at the end of it, and somebody has to pay for the data.

The retail market, at the time of writing, looks roughly like this:

  • Residential, per GB — about $1.75 at the budget end, $3–$6 mid-market, and $8.50–$12 from the big compliance-forward vendors. Volume pricing pulls the enterprise rate down toward $2.50–$3.30 per GB at the terabyte tier, not toward zero.
  • Mobile / 4G-5G, per GB — $2–$15, the most expensive tier, because every relayed gigabyte burns real cellular data. It is also the tier with the highest trust score at your front door, which is a nice piece of poetry.
  • Datacentre, per GB — $0.50–$3, or a flat $0.02–$0.30 per IP per month. Cheap, and irrelevant: those IPs are already trivially identifiable, which is why the attacker was paying the residential premium in the first place.

And the honest user? A home broadband connection is flat-rate. A gigabyte costs them exactly nothing at the margin. It costs them time, and on a modern link, not very much of it.

That asymmetry — the same resource priced at $5/GB for one party and $0.00/GB for the other — is the rarest thing in anti-abuse, and it’s sitting there in plain sight, created by the attacker’s own defensive move.

The asymmetry, in numbers

Take a 250 MB requirement. For the legitimate user, that’s a wall-clock cost:

  • Gigabit fibre: 2 seconds
  • 500 Mbps: 4 seconds
  • 200 Mbps: 10 seconds
  • 100 Mbps: 20 seconds
  • 50 Mbps: 40 seconds
  • 25 Mbps: 80 seconds
  • 10 Mbps: 3m 20s ← this one matters; hold onto it

For the abuser behind a residential proxy, the same 250 MB is a line item:

  • At $1.75/GB (budget tier): $0.44
  • At $5/GB (typical retail): $1.25
  • At $12/GB (enterprise): $3.00

Now apply the multiplier that makes this work. Charge the cost on every attempt, not every success — including the ones your other controls kill, the retries, the resends, the abandoned ones. If 5% of their attempts survive end-to-end, their real cost per working account at $5/GB is $25.

Set that against what the rest of their input stack costs. A human-solved CAPTCHA runs about a tenth of a cent — the 2010 measurement work put it near $1 per thousand solves and the market has only gotten more efficient. A disposable email is free. An SMS-verifiable number is five to fifty cents. So a modest 250 MB transfer requirement is somewhere between 400× and 1,000× more expensive than the most expensive thing currently in their shopping basket, and it’s the only item on the list whose price they can’t negotiate down by buying in bulk.

That’s the whole idea. Everything after this is engineering and honesty.

Why not just use proof-of-work?

Because the asymmetry points the wrong way, and it always has.

Hashcash-style proof-of-work asks the client to burn CPU. But CPU is the attacker’s cheapest and most elastic input — they’re running headless browsers on spot instances or rented GPUs, they parallelise trivially, and compute pricing falls every year. Meanwhile your legitimate user is on a five-year-old Android with a degraded battery and a thermally-throttled SoC, where the same puzzle takes thirty seconds, spins the fans, and gets the tab killed by the OS.

So proof-of-work taxes your poorest users hardest and your attacker least. It is a regressive control dressed up as a fair one. The mechanism isn’t wrong — make the attempt cost something is exactly right — it just picks a resource where the attacker has structural advantage.

Data transfer inverts the ratio precisely. The attacker’s bandwidth is metered and the honest user’s is flat-rate. Better still, the attacker cannot parallelise their way out of it: ten concurrent signups means ten concurrent transfers means ten times the bandwidth spend in the same wall-clock second. Concurrency is what makes proof-of-work cheap for them and what makes proof-of-transfer expensive.

Why not a CAPTCHA?

Because a CAPTCHA hasn’t been a barrier since about 2010; it’s been a price, and a trivially low one. The solving market is mature, human-backed, API-driven, and costs a fraction of a cent per solve. When you add a CAPTCHA you are not stopping the attack — you are adding $0.001 to its unit cost and adding somewhere between four and forty seconds of misery to every legitimate user’s day, including the ones using a screen reader.

The deeper problem is the same one as fingerprinting: it’s a classifier, so there is a right answer to find, and the attacker has unlimited free attempts at finding it. Proof of transfer has no right answer. There is nothing to solve. There’s a bill, and either you paid it or you didn’t. That’s what makes it durable in a way no detection control is.

How to actually enforce it

The naive version is worthless, and it’s worth walking through why, because the fix defines the design. If you serve a 250 MB file and then let the client POST “done” — the attacker simply never fetches the file. The proof has to depend on the bytes.

1. The bytes have to be unforgeable and incompressible

Hold a server secret K. For a given signup attempt, derive a challenge id C and a seed S = HMAC(K, C), then stream N bytes from a keyed stream cipher expanded from S — ChaCha20 with a counter nonce is fine. This buys you four properties at once:

  • Unpredictable. The client cannot produce the bytes without receiving them, because it doesn’t have K.
  • Incompressible. Keyed stream output is indistinguishable from random, so there’s no Content-Encoding: gzip shortcut, no CDN dedup, and no clever middlebox saving them the transfer.
  • Cheap for you to generate. A stream cipher runs at gigabytes per second per core. Your CPU cost rounds to nothing.
  • Stateless. You store nothing but K — any byte range is regenerable from C on demand. That property is what lets this survive an actual flood, and it’s the same argument as ZFN-49Field Note · currentZFN-49 — Verify by computation, not lookup; store revocations, not issuancesVerification should be a computation, not a query: HMACs, signatures, hashes, and pass-by-value claims let any node verify locally. When revocation is rarer than issuance, invert the state — keep the few revocations for the lifetime of what they revoke, not a row per grant.Open ZFN-49 →: verify by computation, not by lookup.

For verification, hash the stream as you generate it and keep the 32-byte digest against C with a short TTL. The client hashes what it receives and returns the digest. Verification is then a 32-byte compare — O(1), and you’re storing 32 bytes per in-flight challenge instead of 250 MB.

Then close the obvious holes: unique URL and Cache-Control: no-store per challenge so nothing is reusable; reject Range requests or account for them properly (otherwise they fetch bytes 0–1000 of a 250 MB object); single-use challenges with a short expiry so a slow trickle can’t be amortised across hours. And rate-limit challenge issuance itself behind some cheap precondition — otherwise you’ve helpfully built an egress amplifier that anyone can point at anything.

2. The proof has to be bound to the network path

This is the one that gets missed, and getting it wrong silently voids the entire scheme.

If the bulk transfer isn’t bound to the connection the signup arrives on, the attacker splits the traffic: route the small API calls through the residential proxy — so your risk engine sees the lovely residential IP it wants — and pull the 250 MB over a plain datacentre link where bandwidth is effectively free. That defeats everything above, and it costs them one line of proxy configuration.

So: record the source address that fetched the stream, and put it inside the MAC’d proof token you issue on completion. The signup endpoint checks that the token’s address matches the address the submission arrived from. Mismatch means no credit and a fresh challenge.

Be honest about the loosening this needs in practice. Real users change address mid-session — CGNAT rotation, Wi-Fi to cellular handoff, ISPs with multiple egress addresses. Bind to a prefix (a /24 for IPv4, /48 or /56 for IPv6) rather than an exact address, and allow one re-issue. That’s a real weakening: an attacker whose proxy egress and datacentre egress sit in the same /24 gets through. In practice they don’t, because the whole point of the residential proxy is that it isn’t in your datacentre’s prefix — but write the caveat down rather than discovering it later.

3. Make them upload, too

Cloud ingress is free on all the major providers, while egress is $0.05–$0.09/GB. The proxy operator, meanwhile, bills upload and download at exactly the same per-gigabyte rate.

That’s the best cost ratio available anywhere in this design: an upload leg costs you nothing and costs them full price. Issue a second seed, have the client upload bytes derived from it, verify by regenerating and comparing digests. It doesn’t matter that the client generated the bytes itself — the bytes still had to cross the proxy, and crossing the proxy is the thing that costs money.

The constraint is that consumer uplinks are asymmetric and slow: cable is typically 10–35 Mbps up, DSL often 1–10. So keep the upload leg an order of magnitude smaller than the download leg — 25 MB up against 250 MB down is 20 seconds on a 10 Mbps uplink, which is tolerable in the background and completely intolerable in the foreground.

4. Hide the latency: gate the value, not the form

This is what makes the whole thing shippable, and it’s a product decision more than a technical one.

Don’t run the transfer on submit. Start it on page load or first field focus, and let it run in the background while the user fills the form, reads the terms, and goes to check their email. The wall-clock overlaps work they were doing anyway, so a 20-second transfer costs a real user zero perceived latency.

Then gate the value, not the form. Create the account immediately. Let the transfer completion unlock the thing the attacker actually came for — the API key, the free credits, outbound send permission, the trial quota. This puts the cost exactly where their value is and takes it entirely out of your conversion funnel.

And the attacker can’t dodge it by backgrounding it too, because their constraint is different: they’re running for volume, so their transfers are concurrent, and concurrent transfers cost concurrent money.

5. Turn the dial with risk

The reason this beats a CAPTCHA architecturally is that it’s continuous. A challenge is binary — you get one setting, and it’s either too weak to bite or too punishing to ship. A byte count is a knob:

  • Returning device, clean history, verified payment method → 0 MB. Charge nothing.
  • Known-good ASN, plausible fingerprint, corporate email domain → 10–25 MB. Barely measurable.
  • Fresh client, disposable email domain, no history → 250 MB.
  • Residential IP with datacentre-adjacent behavioural signature, high-velocity ASN, every marker lit → 1–2 GB, and make a chunk of it upload.

You’re no longer making a binary bot/human judgement you’ll frequently get wrong. You’re setting a price as a monotonic function of your existing risk score — and crucially, when the score is wrong, the failure mode is “this user waited longer than they needed to,” not “this user was banned.”

Where it hurts

I’d rather write these down than have someone find them for me. Some are mitigable, some genuinely aren’t.

Mobile data is metered, and this is the big one. A user on a 5 GB monthly plan cannot spend 250 MB on your signup form, and should not. This is the single worst fairness problem in the design, and it’s not a rounding error — it’s a large fraction of the world’s internet users. Mitigations exist and all of them are partial: the Network Information API can hint at connection type but is unreliable and unimplemented in Safari; you can cap hard on anything that looks cellular; you can require the transfer only above a risk threshold so the vast majority of users never see it. But you must ship an alternative path — email-and-wait, SMS, a card authorisation — for anyone who can’t or shouldn’t pay in bytes. A control with no escape hatch isn’t an anti-abuse measure, it’s a demographic filter.

Slow and expensive connections exist, and not everyone’s are like yours. Go back to that 10 Mbps line in the numbers above: 3 minutes 20 seconds. Rural links, satellite, much of the global south, and plenty of people in wealthy countries. A gigabyte is not the same object everywhere in the world, and calibrating on the connection speed of the people who build the product is exactly how you end up excluding real customers. Calibrate on the p95 of your actual user base, not the median, and absolutely not on your own laptop.

You pay too. At $0.05–$0.09/GB egress, 250 MB per signup is roughly $0.0125–$0.0225 a go. At 100,000 signup attempts a day that’s $1,250–$2,250 a day — and note it lands on attempts, which during an actual attack is exactly when the number spikes. Mitigate by making the upload leg do more of the work (free ingress), serving the download from a cheap-egress provider or a flat-rate CDN, and putting a hard daily ceiling on total challenge bytes so a determined attacker can’t turn your defence into your bill. Model your side of the ledger properly before you ship.

Botnets don’t pay per gigabyte. This is the sharpest limitation. A residential proxy network built on compromised devices pays in the victim’s data allowance, not the operator’s. Proof of transfer bites the SDK-monetised market hard — which is most of the retail proxy industry — and bites a straight criminal botnet very little. There’s a slow feedback loop in your favour (burning a victim’s data cap is exactly how malware gets noticed and removed) but it operates over weeks, not requests. A well-resourced actor with their own botnet is not priced out of your signup flow by this.

“Unlimited” ISP proxies are a partial hole — but read the fair-use terms. ISP and datacentre pools are commonly sold with unmetered bandwidth, which sounds fatal. In practice the unlimited tiers carry fair-use allowances — one major vendor’s is 100 GB per purchased IP per month, pooled, with overage at the normal pay-as-you-go rate. At 250 MB per attempt that’s 400 attempts per IP before the meter starts again. It softens the blow rather than removing it, and those IP ranges are the ones your existing detection is actually still good at.

It’s burnt work, and that’s a real cost. Moving bytes for no purpose other than to make them expensive has an energy and carbon footprint. Published estimates of network energy intensity vary by more than an order of magnitude and are genuinely contested, so I won’t pretend to a number — but it isn’t zero, and “we deliberately wasted bandwidth” deserves to be said out loud rather than buried. The one honest defence is that it’s bounded by physical link speed in a way a hash puzzle isn’t, and you can spend far less of it by dialling with risk rather than charging everyone.

Streaming hashing in a browser is more annoying than it sounds. WebCrypto’s crypto.subtle.digest is one-shot — there is no streaming digest API. So you either buffer 250 MB in memory (bad everywhere, fatal on mobile) or ship a small WASM or JS incremental SHA-256 and feed it from a fetch() ReadableStream. Then handle mobile Safari suspending background fetches on tab backgrounding, add resumability so a dropped connection doesn’t restart from zero, and decide what happens when someone closes the tab at 90%. This is a couple of weeks of real client engineering, not an afternoon.

It does nothing against low-volume, high-value fraud. If a single account is worth $200 to a laundering operation, there is no byte count that is both meaningful to them and tolerable to your users. Proof of transfer is a volume-abuse tool — trial farming, free-tier mining, spam accounts, scraper fleets, referral fraud. Keep your detection stack for the targeted stuff. This raises the floor; it doesn’t replace the building.

The market will adapt, but there’s a floor under it. Providers could introduce automation-tier pricing, and rates will keep drifting down. The structural reason I think this holds up better than CAPTCHAs did: residential bandwidth has a hard cost floor, because the operator is paying real money to real device owners for real metered data. CAPTCHA solve prices collapsed to a tenth of a cent because human attention could be arbitraged to wherever labour was cheapest. Bandwidth can’t be arbitraged the same way — there’s a physical resource with a physical cost underneath it, and the person who owns the connection has to be paid.

How I’d roll it out

  1. Shadow mode first. Ship the transfer, measure, enforce nothing. Collect the completion-time distribution for known-good users at p50/p95/p99, broken out by geography and connection type. You’ll be surprised, and you’d rather be surprised before it’s blocking signups.
  2. Set the requirement from p95, not the median, and set it against the number you actually care about: attacker cost per delivered account, not per attempt.
  3. Enable above a risk threshold only. Most signups should never trigger it.
  4. Background it and gate the value, not the form — before you turn on enforcement, not after someone shows you the conversion chart.
  5. Watch the abandonment rate by decile of required bytes. That curve tells you where your real ceiling is, and it won’t be where you guessed.

The point

Proof of transfer isn’t a silver bullet, and I’ve spent a third of this post on the places it fails because a defence you can’t describe the limits of isn’t one you understand. It won’t stop a botnet. It won’t stop targeted fraud. It has a real fairness cost that you have to actively engineer around, and it puts a line on your own bill.

But it’s the first anti-abuse primitive I’ve seen in a decade whose asymmetry points the right way, and it gets that asymmetry from the attacker’s own defensive move: the thing they bought to become invisible is the thing that’s metered. It’s continuously dialable rather than binary. It doesn’t care whether the client is a human, a browser, or a Go program — which means it isn’t in the automation arms race at all. And there’s no boundary to probe, no model to reverse, no puzzle to farm out.

There’s just an invoice. Either they paid it or they didn’t.


← All writing