July 6, 2026 · 12 min read · Anti-bot · Browser Automation

How we solved Cloudflare Turnstile + email OTP fully unattended (Hunter.io walkthrough)

On 2026-06-13, at around 03:11 UTC, a Human Browser session opened hunter.io/users/sign_up, filled the form, solved a Cloudflare Turnstile challenge, waited for the verification email, extracted the six-digit code, submitted it, and dropped the resulting API key (3a855a...) into the audit log. Total wall time: about five minutes. Total cost: roughly $0.16 — $0.06 to CapSolver for the Turnstile solve and about $0.10 in LLM tokens for the agent's plan-and-verify loop. Zero human clicks.

That's the win. It doesn't sound like much until you factor in what the alternative looked like a week earlier: a virtual assistant at $10-$15 an hour doing exactly this, taking ten minutes of their time (five minutes of clicks plus five of context switching), producing an API key that costs somewhere around $2-$3 in labor. Automating this cleanly turns a $3 unit cost into a $0.16 unit cost, but more importantly it removes the human as a bottleneck: you can run one Hunter signup at a time or two hundred at a time, and the operational plumbing is the same.

Getting to unattended took three specific patches to our detector, one new watcher pattern, one fix to our solver-token injection, and one embarrassing bug in the OTP extractor. This post walks through all of them, with the actual function names from the Human Browser codebase and audit-log excerpts from the run that eventually succeeded.

Contents

  1. Why generic captcha detection misses modern Turnstile
  2. The detector patch
  3. The delayed-poll watcher
  4. Solver token injection: events and callbacks
  5. The OTP-fetcher trap: CSS hex vs real codes
  6. End-to-end walkthrough: hunter.io signup
  7. What this unlocks
  8. FAQ

Why generic captcha detection misses modern Turnstile

The public Turnstile integration example on Cloudflare's docs is a single line:

<div class="cf-turnstile" data-sitekey="0x4AAA..." data-callback="onCaptchaOK"></div>

Any captcha detector written in the last two years will look for that class. If it's there, you know you're facing Turnstile and you know the site key. Our old findInDoc() in session-server.js did exactly that. It worked on maybe 60% of Turnstile-protected sites we encountered — the ones that used the vanilla integration.

Hunter.io does not use the vanilla integration. The signup page is a Stimulus.js Rails app: the widget is mounted by a controller like this (paraphrased from what we saw when we spelunked the page):

<div
  data-controller="turnstile"
  data-turnstile-sitekey-value="0x4AAA...redacted..."
  data-turnstile-action-value="signup"
  data-turnstile-callback-value="handleSignupToken"
></div>

There is no cf-turnstile class anywhere. The site key is on a data-* attribute prefixed with the controller name. The callback is a string that Stimulus later resolves against a method on the controller instance. And to make things worse, the actual iframe that Turnstile injects doesn't appear until the Stimulus controller mounts — which for some routes happens on scroll, not on DOM ready.

The result: our old detector fired the audit event captcha_lifecycle stage=missed kind=detector_blindspot and the agent shrugged and clicked submit, at which point Hunter's server-side gate rejected the form for missing the Turnstile token. Rinse, repeat, waste $0.30 in LLM tokens per attempt, give up.

The old detector looked for one CSS class. Modern Turnstile hides behind Stimulus controllers, React refs, dynamic iframes, and occasionally a shadow root. One class check is not enough.

The detector patch

The fix lives in server/prototype/session-server.js at around line 2186, inside the function detectCaptchaOnPage(). The relevant sub-routine is findInDoc(), which now scans four surfaces instead of one. Simplified for readability:

function findInDoc(doc) {
  const hits = [];

  // (1) The old check — still valuable for vanilla integrations.
  const vanilla = doc.querySelector('[class*="cf-turnstile"]');
  if (vanilla) hits.push({ kind: 'turnstile', via: 'class', el: vanilla });

  // (2) NEW: iframe URL scan — the Turnstile widget mounts an iframe from
  //     challenges.cloudflare.com. If it's already mounted, we can read the
  //     sitekey out of the src query string.
  for (const iframe of doc.querySelectorAll('iframe')) {
    const src = iframe.getAttribute('src') || '';
    if (src.includes('challenges.cloudflare.com/cdn-cgi/challenge-platform')) {
      const sitekey = new URL(src, location.origin)
        .searchParams.get('__cf_chl_tk') || extractSitekeyFromUrl(src);
      hits.push({ kind: 'turnstile', via: 'iframe_url', sitekey, el: iframe });
    }
  }

  // (3) NEW: inline-script scan — some sites call turnstile.render() imperatively
  //     from a <script> block. If we see the call in the source, we know we're
  //     facing Turnstile even before the widget mounts.
  for (const script of doc.querySelectorAll('script:not([src])')) {
    const body = script.textContent || '';
    if (/turnstile\s*\.\s*render\s*\(/.test(body)) {
      const sitekey = /sitekey["'\s:=]+([0x4A-Za-z0-9]+)/.exec(body)?.[1];
      hits.push({ kind: 'turnstile', via: 'inline_script', sitekey });
    }
  }

  // (4) NEW: data-* attribute scan — for Stimulus / Alpine / React sites
  //     that stash the sitekey on any element.
  for (const el of doc.querySelectorAll('[data-controller], [data-sitekey], [data-turnstile-sitekey-value]')) {
    const attrs = el.attributes;
    for (let i = 0; i < attrs.length; i++) {
      const a = attrs[i];
      if (/sitekey/i.test(a.name) && /^0x[0-9A-Fa-f]/.test(a.value)) {
        hits.push({ kind: 'turnstile', via: 'data_attr', sitekey: a.value, el });
      }
    }
  }

  // (5) NEW: shadow-DOM walker — a few enterprise CF customers wrap Turnstile
  //     inside a custom element's shadow root. Recurse manually.
  for (const el of doc.querySelectorAll('*')) {
    if (el.shadowRoot) {
      hits.push(...findInDoc(el.shadowRoot));
    }
  }

  return hits;
}

The pattern is: don't try to guess the site's framework. Enumerate every place the Turnstile signal could be hiding — iframe URL, imperative call, data attribute, shadow root — and treat any hit as a detection. False positives are cheap (we just try to solve, and if the token isn't accepted the retry loop catches it); false negatives are expensive (agent submits a broken form and burns LLM tokens on a doomed loop).

Adding new blind spots is now the pattern for every new site: look at the captcha_lifecycle stage=missed kind=detector_blindspot audit line, note the iframe URL or the input names or the data attribute the site used, add a case to findInDoc(). Not a rewrite, just an accretion.

The delayed-poll watcher

Even after the detector patch, our first Hunter run still failed. The audit log looked like this:

[stage=probe] target=hunter.io/users/sign_up
[stage=detected] captcha=none (findInDoc returned 0 hits)
[stage=fill_form] email, password, name typed
[stage=submit] click #signup-button
[stage=blocked] server rejected: "cf-turnstile-response missing"

The problem: at the moment we probed (DOM ready), the Stimulus controller had not yet mounted the Turnstile widget. Hunter's controller registers a connect() lifecycle hook that renders Turnstile only after Stimulus initializes — which can happen 3-5 seconds after DOM ready, depending on how heavy the JS bundle is on the visitor's connection.

Our first attempt at fixing this was the obvious one: wait 5 seconds, then probe again. That worked on Hunter about 70% of the time. The other 30%, the controller wouldn't mount until the visitor scrolled (a lazy-mount optimization). Scrolling programmatically to force the mount fixed those cases, but was fragile — some routes would rerun the controller and blow away the token we'd already injected.

The pattern that stuck is a four-pass watcher with growing delay, running in parallel with the agent's other actions:

// runs on every page load, in the background, non-blocking
async function watchForCaptcha(page) {
  const delays = [500, 1500, 3000, 5000]; // ms — total 10s of coverage
  for (const d of delays) {
    await sleep(d);
    const hits = await page.evaluate(() => findInDoc(document));
    if (hits.length > 0) {
      audit('captcha_lifecycle', { stage: 'detected', pass: delays.indexOf(d) + 1, hits });
      return hits;
    }
  }
  return [];
}

Four passes at 0.5, 1.5, 3, and 5 seconds — cumulative 10 seconds of coverage. Cheap. Runs in the background so the agent's own actions can proceed if the site doesn't need a captcha. On Hunter, the detection now consistently fires on pass 3 (about 3s in), giving us the sitekey before the agent is even ready to submit.

Solver token injection: events and callbacks

Once CapSolver returns a Turnstile token — usually 20-40 seconds after we hand it the sitekey and the page URL — we need to inject it back into the page in a way that both the Cloudflare widget and the site's own code accept. Cloudflare's docs say to call the site's callback function directly. Stimulus sites don't have a global callback function; the callback is a method on a controller instance the site owns.

Our injection pattern now hits both channels:

await page.evaluate((token) => {
  // (1) Set the hidden input the vanilla widget writes to. Some server
  //     validators read this directly on submit.
  const input = document.querySelector('input[name="cf-turnstile-response"]');
  if (input) { input.value = token; input.dispatchEvent(new Event('change', { bubbles: true })); }

  // (2) Fire the site's declared callback if it exists on window.
  //     data-callback="onCaptchaOK" style integrations expect this.
  const candidates = ['onCaptchaOK', 'onTurnstileSuccess', '__turnstileCallback'];
  for (const name of candidates) {
    if (typeof window[name] === 'function') {
      try { window[name](token); } catch (e) { /* keep going */ }
    }
  }

  // (3) Dispatch a custom event that Stimulus / React controllers can listen for.
  //     Modern controllers wire this in their connect() and treat the event
  //     as the source of truth.
  document.dispatchEvent(new CustomEvent('turnstileToken', {
    detail: { token },
    bubbles: true
  }));

  // (4) If Turnstile's own client is loaded, invoke its execute-completed hook
  //     directly. This is a private API but it's stable across versions.
  if (window.turnstile && window.turnstile._completed) {
    window.turnstile._completed(token);
  }
}, token);

The insight — obvious in hindsight, painful to arrive at — is that different sites listen on different channels, and there's no reliable way to introspect which one a given site uses without reverse-engineering it. So we fire all four and let whichever one is wired in do its job. The redundancy is free: the hidden input write is idempotent, the callbacks are no-ops if not defined, the event has no listener on sites that don't use it.

Hunter picks up the token through the CustomEvent path — the Stimulus controller's connect() hook registers a document-level listener for turnstileToken. We only discovered this by reading the minified controller source, which is how you always end up figuring out these things.

The OTP-fetcher trap: CSS hex vs real codes

By this point the Turnstile side was solved. The agent could get past the challenge, submit the form, and Hunter would send a verification email to the disposable inbox we control. Now the OTP fetcher had to pull the six-digit code out of that email.

The naive regex is \d6. That worked in our unit tests. It did not work on Hunter's actual email, which looks (paraphrased) like this:

<html>
  <body style="background:#f4f4f4; color:#333333;">
    <div style="padding:20px; background:#4a90e2;">
      <h1 style="color:#ffffff;">Verify your email</h1>
    </div>
    <p>Your verification code is:</p>
    <p style="font-size:32px; font-family:monospace;">438291</p>
    <p style="color:#999999;">This code expires in 15 minutes.</p>
  </body>
</html>

Our OTP fetcher confidently returned 333333, which is a CSS hex color code that happens to match \d6. The agent then typed 333333 into Hunter's code input, got rejected, retried on the next email, got another false code from another color, and eventually gave up. The most galling part is that this was happening while the Turnstile solve — the "hard" part — was working perfectly. We spent an evening thinking Turnstile had regressed before checking the OTP extractor.

The fix is context-aware extraction. Only match \d6 tokens that live near verification-language keywords in the visible body text, not inside style attributes:

function extractOTP(emailHtml) {
  // Strip style attributes and <style> blocks before matching — colors live there.
  const stripped = emailHtml
    .replace(/<style[\s\S]*?<\/style>/gi, '')
    .replace(/style="[^"]*"/gi, '')
    .replace(/<[^>]+>/g, ' '); // strip all tags

  // Only match near keywords. Window of ~120 chars before/after.
  const keywords = /\b(verif|code|otp|one[-\s]?time|confirm)/i;
  const matches = stripped.matchAll(/\b(\d{6})\b/g);
  for (const m of matches) {
    const start = Math.max(0, m.index - 120);
    const end = Math.min(stripped.length, m.index + m[0].length + 120);
    const context = stripped.slice(start, end);
    if (keywords.test(context)) return m[1];
  }
  return null;
}

The stripping is important — a lot of transactional emails inline colors as style="background:#4a90e2", and if you naively strip only <style> blocks you'll still match on those. The context window is deliberately generous (120 chars) because email templates put the code on one line and the "your verification code is" text several lines above.

This same extractor now handles OTPs for Apollo, ZeroBounce, Snov, and any other transactional email we've thrown at it. It has never once returned a color code again.

End-to-end walkthrough: hunter.io signup

With all three patches in place, here is what the 2026-06-13 03:11 UTC run looked like in the audit log. Times are relative to t=0, the moment the A2A task was accepted:

t+0.0s   [stage=task_accepted] goal="sign up to hunter.io with email hb-eval-{id}@vlab.mail"
t+0.4s   [stage=session_ready] engine=patchright profile=hunter-eval-{id} proxy=us-sticky
t+2.1s   [stage=nav_ok] url=https://hunter.io/users/sign_up http=200
t+3.6s   [stage=detected] via=data_attr sitekey=0x4AAA...redacted pass=3
t+3.6s   [stage=solving] provider=capsolver taskId=84f7c1a9-...
t+4.9s   [stage=fill_form] fields=[email,password,first_name,last_name] method=react_safe_input
t+29.2s  [stage=solved] token=cf-chl-... (length=1420) elapsed_ms=25580 cost_usd=0.058
t+29.4s  [stage=injected] channels=[input,callback,event,turnstile_api] ok=true
t+30.1s  [stage=submit] click #signup-button
t+31.8s  [stage=nav_ok] url=https://hunter.io/verify?email=...
t+31.9s  [stage=waiting_email] mailbox=hb-eval-{id}@vlab.mail poll_interval_s=5
t+108.3s [stage=email_received] [email protected] subject="Verify your email"
t+108.4s [stage=otp_extracted] code=438291 method=context_aware near_keyword="verification code"
t+109.6s [stage=fill_form] field=verification_code value=438291
t+110.3s [stage=submit] click #verify-button
t+112.7s [stage=nav_ok] url=https://hunter.io/api-keys
t+114.9s [stage=api_key_captured] key=3a855a... source=dom_selector[".api-key-copy"]
t+115.1s [stage=task_complete] cost_usd_total=0.163 llm=0.104 solver=0.058 browser=0.001

A few things to notice. The Turnstile detection happens on pass 3 of the watcher (t+3.6s), which is a hair under 3.6 seconds after DOM ready. CapSolver took 25.5 seconds to return the token (t+29.2s), which is roughly normal for their Turnstile queue — sometimes 15s, sometimes 40s, we've never seen it above 60s. The waiting-for-email step is the longest single segment (about 77 seconds), which is entirely on Hunter's transactional email pipeline. The OTP extractor pulled the correct code on first try. Total cost was $0.163, split $0.058 to CapSolver, $0.104 to the LLM (mostly gpt-5.1 with occasional gemini-2.5-flash fast-rail steps for cheap navigation actions), $0.001 to the browser session itself.

Total wall time: 115 seconds. Total cost: $0.16. Zero human clicks. This used to be a $3 VA task.

What this unlocks

Hunter.io is one of about a dozen sites in what we internally call the "anti-bot SaaS" cluster: Hunter, Apollo, ZeroBounce, Findymail, Snov, Lusha, LeadIQ, RocketReach, and a handful of others. They all sell some flavor of email discovery or verification, they all sign up via Cloudflare Turnstile plus email OTP, and until 2026-06-13 we had a running joke that they were the one class of signup we couldn't unattend cleanly. Every one of them used to fall through our detector in a slightly different way, and every one of them used to escalate to a human-in-the-loop step within an hour.

After the Hunter patches, the pattern generalizes. We've since successfully driven the full signup flow on:

Findymail and Snov are queued but not yet run. Based on their public signup pages they look like variants we've already handled.

The broader lesson from this whole effort — and it's the one we keep learning — is that solving anti-bot problems is not one big fix, it's a hundred small ones. Every site introduces a new blind spot; every blind spot becomes a new case in findInDoc() or a new pass in the watcher or a new channel in the injector. What matters is that the audit-log surface is precise enough to tell you exactly which stage broke on which site, so you can patch instead of rewrite.

FAQ

What is Cloudflare Turnstile and why is it hard to solve programmatically?

Turnstile grades a browser session across TLS fingerprint, canvas + WebGL entropy, mouse/keyboard traces, and platform-consistency probes, then either returns a token silently or forces an interactive challenge. It's hard because a solver alone isn't enough — you also need a browser session that grades as human. And even once you have the token, the site's own JavaScript has to accept it: modern sites bind Turnstile through Stimulus.js controllers or React refs, not through the vanilla data-callback attribute, so injecting the token is a per-site puzzle.

Why did the standard cf-turnstile div detector miss Stimulus-controller sites?

Because there is no cf-turnstile div on those sites. Hunter renders the widget via a Stimulus controller (data-controller="turnstile") that reads the site key off a data attribute and calls turnstile.render() imperatively. The widget mounts inside a nested div — sometimes inside a shadow root — with no cf-turnstile class. The fix scans four surfaces: iframe src patterns for challenges.cloudflare.com, inline script bodies for turnstile.render(, data-* attributes on any element, and shadow roots recursively.

Does this work on Apollo.io, ZeroBounce, Snov?

Apollo (validated 2026-06-14) and ZeroBounce (validated 2026-06-15) — yes. Snov and Findymail are queued. Each has small quirks in how it binds the token back to its form; the audit log tells you which stage broke so patches are additive.

What does one Hunter.io signup actually cost via Human Browser?

About $0.16 end-to-end. Roughly $0.06 for the CapSolver Turnstile solve, $0.10 in LLM tokens for the agent's plan-and-verify loop, and about $0.001 for browser minutes. Compared to a VA at $10-15 an hour taking ten minutes per signup, this is a ~15-20x cost reduction, but the bigger unlock is removing the human as a serial bottleneck.

How does OTP extraction work — do you have inbox access?

The signup uses a disposable inbox we control (per-run, per-domain). After the site sends the verification email, our OTP fetcher pulls the message via IMAP, runs a context-aware extractor over the body, and returns the code. Context-aware means: strip style attributes and tags first (colors live there), then only match \d6 tokens within a ~120-char window of keywords like "verify" / "code" / "OTP" — otherwise you'll happily return CSS hex color codes and wonder why the signup keeps rejecting them.

Is this an ethical gray area?

Honest answer: yes, for anything that isn't your own evaluation flow. Cloudflare Turnstile exists in part to slow abusive automated signups. We use this pattern to evaluate anti-bot providers for our own product and to help customers automate signups to services they legitimately pay for and want programmatic access to (their own accounts, their own API keys). We don't recommend it for spam accounts, throwaway abuse, or violating anyone's terms of service. If a service offers a legitimate API tier, use that instead — it's cheaper and less brittle than a signup pipeline.

Want to try this on your own signup flow?

Human Browser gives you a stealth Chromium session, sticky residential proxies, captcha solving, and an LLM agent loop for pay-as-you-go: $0.10/hr browser time, $4/GB residential proxy, $0.005/captcha, $0.02 per agent step, LLM included. No subscription. Try it for $1 at humanbrowser.cloud and POST your first task to /a2a in under two minutes.

Related Articles

Top up — $20

Loading secure checkout…

More coins →