DEV Community

Cover image for We Ditched CAPTCHA and SMS for Proof of Work. Here's Why Your Agent Can't Use Email.
Atomic Mail
Atomic Mail

Posted on

We Ditched CAPTCHA and SMS for Proof of Work. Here's Why Your Agent Can't Use Email.

Every signup flow faces the same question: how do you stop bots from farming accounts?

The standard answer is CAPTCHA or SMS. Both look fine in a demo. Both fall apart when the client calling your API is a machine instead of a human.

We built Atomic Mail for AI agents to send email. That meant the signup flow couldn't depend on humans clicking checkboxes or reading text messages. We needed something that worked for autonomous clients, was cheap to verify server-side, and made account farming expensive enough that it actually mattered.

We chose Proof of Work. Here's what we learned.

Why CAPTCHA and SMS Don't Work for Agents

CAPTCHA looks like the obvious choice for "prove you're human." Except your client isn't human. It's an LLM.

Modern LLMs can solve some CAPTCHAs. Not consistently. Not cheaply. And more importantly: if an AI can solve a CAPTCHA, so can a determined attacker with a CAPTCHA-solving service ($0.001-0.01 per solve). You're not blocking bots. You're just adding a tax.

The real problem: CAPTCHA assumes a human is at the keyboard. If your service is an API and the caller is a machine, CAPTCHA becomes either:

  • A wall that breaks legitimate automated access
  • A solved problem that doesn't slow down actual attackers

SMS sounds better. "Verify via text message." Except:

  • Not every country has SMS (or it's expensive)
  • Your legitimate users hate it
  • You need a phone provider integration (cost + latency)
  • Bots can use SMS services too (they're just a few dollars per number)

Neither blocks determined attackers. Both punish legitimate users. And both assume a human is involved somewhere.

The Insight: The Client Is Always a Machine

Here's the shift in thinking that changes everything.

Atomic Mail's primary client isn't a browser. It's code. An agent. A CLI tool. An MCP server. All of these are machines. They don't have eyes. They don't have phone plans.

When your client is a machine, you stop thinking about CAPTCHAs and start thinking about cost.

Question: What's the cheapest way to prove you're not farming 10,000 accounts?

Answer: Make it expensive to farm 10,000 accounts.

This is where Proof of Work comes in. Not as a security measure. As an economics measure. You put a real CPU cost on account creation. Not so much that a single signup takes forever. But enough that farming accounts at scale stops being free.

Proof of Work: The Boring Solution That Actually Works

A proof of work is a puzzle that's cheap to verify but expensive to compute. You hand it to the client, they grind on it (burning CPU cycles), and when they come back with a solution, you can verify it in milliseconds.

The classic is Hashcash: find a number such that SHA256(data + number) starts with N zeros. Verify: one hash computation.

For account farming defense, Hashcash-style PoW has a problem: it's not expensive enough. GPU and ASIC farms absolutely destroy it. You'd need impractical difficulty to actually slow down a well-funded attacker.

We chose scrypt instead.

scrypt(N=16384, r=8, p=1, dklen=64)

Scrypt is memory-hard. The bottleneck isn't CPU speed. It's memory bandwidth. An ASIC farm that can compute SHA256 a billion times per second gets no special advantage against scrypt because the memory wall hits everyone equally.

Here's what a PoW challenge and solution look like:

// SERVER: Challenge sent to client
{
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "difficulty": 6,
  "salt": "your-public-salt-constant",
  "algorithm": "scrypt"
}

// CLIENT: Grind until difficulty bits match
const scrypt = require('scryptsy');
let nonce = 0;
while (true) {
  const hash = scrypt.hash(
    Buffer.from(`${challenge.jti}:${nonce}`),
    Buffer.from(salt),
    16384, 8, 1, 64
  );
  const leadingZeros = countLeadingZeros(hash);
  if (leadingZeros >= difficulty) {
    return nonce; // Found it
  }
  nonce++;
}

// SERVER: Verify in milliseconds
const computed = scrypt.hash(
  Buffer.from(`${jti}:${nonce}`),
  Buffer.from(salt),
  16384, 8, 1, 64
);
if (countLeadingZeros(computed) >= difficulty) {
  // Valid proof
}
Enter fullscreen mode Exit fullscreen mode

The client burns real CPU and RAM to find a valid nonce. The server verifies it's correct in one scrypt call (~16MB allocation, ~100ms on modern hardware). Asymmetric cost: grinding is hard, verification is fast.

The Economics of Account Farming

Let's say a clean signup requires difficulty 4. An attacker setting up a botnet:

Per account: 2^4 = ~16 expected nonce iterations (memory-hard, ~100ms each)
Time per account: ~1-2 seconds of CPU time
1,000 accounts: ~20-30 minutes of sustained CPU on a single machine
10,000 accounts: ~3-5 hours of CPU

That doesn't sound expensive. Until you realize:

  1. They're doing it at scale. 10,000 accounts are nothing. Real farming is 100,000+.
  2. PoW verification is per-request, not per-account. The server cost is negligible. Their cost is real.
  3. Difficulty scales with IP risk. A known-bad IP pays difficulty 8. That's 2^8 = ~256x harder. Now we're talking hours of CPU per account. On a cloud instance ($0.10/hour compute), farming 10,000 accounts at difficulty 8 costs real money. Not impossible money. But money that has to be factored into the ROI of the spam operation.

The Catch: Your Client Has to Support It

PoW only works if your legitimate clients are willing to grind.

An SDK/CLI/agent that never implements PoW verification? Congratulations, you just built a wall that only blocks the attackers who read your docs.

We implemented scrypt in three languages:

  • TypeScript (twice: Node.js and browser, different implementations)
  • Python

All three have to produce byte-identical output. Test vectors exist. Every change to a parameter, every encoding mistake, every salt format issue breaks interop.

Here's the guard that caught a real mistake:

# WRONG: decode hex salt
salt = bytes.fromhex(salt_hex)  # ❌ Produces wrong bytes

# RIGHT: use salt as UTF-8 string
salt = salt_string.encode('utf-8')  # ✅ All implementations agree
Enter fullscreen mode Exit fullscreen mode

The salt in our challenge is a hardcoded public string (not secret, because PoW is a cost function not a password hash). One implementation decoded it from hex. The others didn't. All three produced different digests. All three would fail verification. The client would get stuck on every signup.

We caught this with golden test vectors. Every implementation is tested against the same challenge → nonce → hash triplets. A single parameter drift fails CI in every language at once.

Reputation-Scaled Difficulty

One global difficulty for all IPs is wrong. A brand-new account from a clean IP should not pay the same cost as a signup from a known-bad network.

We use IP risk tiers:

Clean IP:          difficulty 4   (16x iterations)
Moderate IP:       difficulty 6   (64x iterations)
High-risk IP:      difficulty 8   (256x iterations)
Critical/blocked:  rejected at signup (no PoW, just no)
Enter fullscreen mode Exit fullscreen mode

A normal user signs up: ~1-2 seconds of grinding. An attacker on a listed IP: ~10-30 seconds per account. That compounds across thousands of attempts.

Trade-Offs You Should Know About

The honest part:

PoW isn't a silver bullet. It's a friction tax. An attacker with money and patience can still farm accounts; they're just paying for the CPU cycles. A single CAPTCHA-solving service costs $0.001. Scrypt-solving at difficulty 8 costs ~$0.01-0.05 per account in compute. It's not free anymore, but it's not impossible either.

What PoW actually buys:

  • Spam farming stops being literally free
  • Your infrastructure doesn't bear the cost of verification (unlike CAPTCHA services)
  • Legitimate clients (agents, CLIs, SDKs) work without needing JavaScript or SMS
  • Every client implementation is a test of your protocol correctness

The limitation:

If an attacker compromises your client SDK and bakes in a pre-solved PoW nonce, they bypass it completely. The defense is social (you notice a client doing weird things) and rate-limit-based (even with solved PoW, you cap signups per IP per minute).

Why This Matters for Agents

Atomic Mail's entire premise is: agents should be able to handle their own email.

A signup flow that requires "click this image" or "enter the code from your text message" is a flow that agents can't use. And if agents can't sign up, they can't send mail.

PoW lets us say: prove you're not spam by doing work. The agent can do that work. A human can do that work. A CAPTCHA farm can do it cheaper than the agent. But at least it's not impossible.

The client stays simple. The server cost is minimal. The attacker pays real money instead of outsourcing to a CAPTCHA service. That's the win.

Top comments (0)