Skip to main content

Guide

Turnstile vs reCAPTCHA vs hCaptcha 2026

Compare Cloudflare Turnstile, Google reCAPTCHA, and hCaptcha for bot protection in web applications. Invisible challenges, privacy, accessibility, and which.

·PkgPulse Team·
0

TL;DR

Cloudflare Turnstile is the privacy-first CAPTCHA alternative — invisible challenges, no visual puzzles, free, doesn't sell user data, uses Cloudflare's bot detection signals. reCAPTCHA is Google's CAPTCHA — reCAPTCHA v3 scores user behavior invisibly, v2 shows "I'm not a robot" checkbox, the most widely used, free with Google's data collection. hCaptcha is the privacy-focused CAPTCHA — visual challenges, site owners earn credits, GDPR-compliant, used by Cloudflare (before Turnstile) and Discord. In 2026: Turnstile for invisible privacy-first bot protection, reCAPTCHA for maximum bot detection with Google's network, hCaptcha for privacy-respecting visual challenges.

Key Takeaways

  • Turnstile: Free — invisible, no puzzles, privacy-first, Cloudflare network signals
  • reCAPTCHA: Free (with data) — v3 invisible scoring, v2 checkbox, Google ecosystem
  • hCaptcha: Free tier — visual challenges, privacy-focused, GDPR-compliant, earns credits
  • Turnstile never shows visual puzzles to users — fully invisible
  • reCAPTCHA v3 is also invisible but sends data to Google
  • hCaptcha is the only one where site owners can earn money

Cloudflare Turnstile

Turnstile — invisible bot protection:

Client-side widget

<!-- Add Turnstile widget: -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="/api/submit" method="POST">
  <input type="text" name="email" placeholder="Email" />

  <!-- Turnstile widget (renders invisibly): -->
  <div class="cf-turnstile"
       data-sitekey="0x4AAAAAAA..."
       data-theme="dark"
       data-callback="onTurnstileSuccess">
  </div>

  <button type="submit">Submit</button>
</form>

<script>
  function onTurnstileSuccess(token) {
    console.log("Turnstile token:", token)
  }
</script>

React integration

import { useRef, useEffect, useState } from "react"

function TurnstileWidget({ siteKey, onVerify }: {
  siteKey: string
  onVerify: (token: string) => void
}) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!ref.current || !window.turnstile) return

    const widgetId = window.turnstile.render(ref.current, {
      sitekey: siteKey,
      callback: onVerify,
      theme: "dark",
    })

    return () => window.turnstile.remove(widgetId)
  }, [siteKey, onVerify])

  return <div ref={ref} />
}

// Usage:
function ContactForm() {
  const [token, setToken] = useState("")

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await fetch("/api/contact", {
      method: "POST",
      body: JSON.stringify({ token, /* form data */ }),
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <TurnstileWidget
        siteKey="0x4AAAAAAA..."
        onVerify={setToken}
      />
      <button type="submit" disabled={!token}>Send</button>
    </form>
  )
}

Server-side verification

// Verify Turnstile token on server:
async function verifyTurnstile(token: string, ip?: string): Promise<boolean> {
  const response = await fetch(
    "https://challenges.cloudflare.com/turnstile/v0/siteverify",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: process.env.TURNSTILE_SECRET_KEY,
        response: token,
        remoteip: ip,
      }),
    }
  )

  const data = await response.json()
  return data.success === true
}

// Express route:
app.post("/api/contact", async (req, res) => {
  const { token, email, message } = req.body

  const valid = await verifyTurnstile(token, req.ip)
  if (!valid) {
    return res.status(403).json({ error: "Bot detected" })
  }

  // Process form...
  res.json({ success: true })
})

reCAPTCHA

reCAPTCHA — Google bot detection:

reCAPTCHA v3 (invisible scoring)

<!-- Load reCAPTCHA v3: -->
<script src="https://www.google.com/recaptcha/api.js?render=SITE_KEY"></script>

<script>
  grecaptcha.ready(function() {
    // Execute on form submit:
    document.getElementById("form").addEventListener("submit", async (e) => {
      e.preventDefault()

      const token = await grecaptcha.execute("SITE_KEY", {
        action: "submit_form",
      })

      // Send token to server:
      await fetch("/api/submit", {
        method: "POST",
        body: JSON.stringify({ token }),
      })
    })
  })
</script>

React integration (v3)

import { useCallback } from "react"

// Load reCAPTCHA v3 script in layout:
// <script src="https://www.google.com/recaptcha/api.js?render=SITE_KEY"></script>

function useRecaptcha(siteKey: string) {
  const execute = useCallback(async (action: string): Promise<string> => {
    return new Promise((resolve) => {
      window.grecaptcha.ready(async () => {
        const token = await window.grecaptcha.execute(siteKey, { action })
        resolve(token)
      })
    })
  }, [siteKey])

  return { execute }
}

// Usage:
function LoginForm() {
  const { execute } = useRecaptcha("SITE_KEY")

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    const token = await execute("login")

    await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ token, /* credentials */ }),
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Log In</button>
    </form>
  )
}

Server-side verification

// Verify reCAPTCHA token:
async function verifyRecaptcha(token: string): Promise<{
  success: boolean
  score: number
  action: string
}> {
  const response = await fetch(
    "https://www.google.com/recaptcha/api/siteverify",
    {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        secret: process.env.RECAPTCHA_SECRET_KEY!,
        response: token,
      }),
    }
  )

  return response.json()
}

// Express route with score threshold:
app.post("/api/login", async (req, res) => {
  const { token, email, password } = req.body

  const result = await verifyRecaptcha(token)

  if (!result.success || result.score < 0.5) {
    return res.status(403).json({ error: "Bot detected", score: result.score })
  }

  // Score 0.0 = likely bot, 1.0 = likely human
  if (result.score < 0.7) {
    // Low confidence — add additional verification
    return res.json({ requireMFA: true })
  }

  // High confidence — proceed normally
  // Authenticate user...
})

reCAPTCHA v2 (checkbox)

<!-- "I'm not a robot" checkbox: -->
<script src="https://www.google.com/recaptcha/api.js" async defer></script>

<form action="/api/submit" method="POST">
  <input type="email" name="email" />
  <div class="g-recaptcha" data-sitekey="SITE_KEY" data-theme="dark"></div>
  <button type="submit">Submit</button>
</form>

hCaptcha

hCaptcha — privacy-focused CAPTCHA:

Client-side widget

<!-- Load hCaptcha: -->
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>

<form action="/api/submit" method="POST">
  <input type="email" name="email" />

  <div class="h-captcha"
       data-sitekey="SITE_KEY"
       data-theme="dark"
       data-size="normal">
  </div>

  <button type="submit">Submit</button>
</form>

React integration

import HCaptcha from "@hcaptcha/react-hcaptcha"
import { useRef, useState } from "react"

function SignupForm() {
  const [token, setToken] = useState("")
  const captchaRef = useRef<HCaptcha>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!token) {
      // Trigger captcha:
      captchaRef.current?.execute()
      return
    }

    await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify({ token, /* form data */ }),
    })

    // Reset after submission:
    captchaRef.current?.resetCaptcha()
    setToken("")
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />

      <HCaptcha
        ref={captchaRef}
        sitekey="SITE_KEY"
        theme="dark"
        onVerify={setToken}
        onExpire={() => setToken("")}
        onError={() => setToken("")}
      />

      <button type="submit">Sign Up</button>
    </form>
  )
}

Invisible mode

import HCaptcha from "@hcaptcha/react-hcaptcha"

function InvisibleForm() {
  const captchaRef = useRef<HCaptcha>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    // Triggers invisible challenge:
    captchaRef.current?.execute()
  }

  const onVerify = async (token: string) => {
    await fetch("/api/submit", {
      method: "POST",
      body: JSON.stringify({ token }),
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <HCaptcha
        ref={captchaRef}
        sitekey="SITE_KEY"
        size="invisible"
        onVerify={onVerify}
      />
      <button type="submit">Submit</button>
    </form>
  )
}

Server-side verification

// Verify hCaptcha token:
async function verifyHCaptcha(token: string, ip?: string): Promise<boolean> {
  const response = await fetch("https://api.hcaptcha.com/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      secret: process.env.HCAPTCHA_SECRET_KEY!,
      response: token,
      ...(ip && { remoteip: ip }),
    }),
  })

  const data = await response.json()
  return data.success === true
}

// Express route:
app.post("/api/signup", async (req, res) => {
  const { token, email, password } = req.body

  const valid = await verifyHCaptcha(token, req.ip)
  if (!valid) {
    return res.status(403).json({ error: "CAPTCHA verification failed" })
  }

  // Create account...
  res.json({ success: true })
})

Feature Comparison

FeatureTurnstilereCAPTCHAhCaptcha
ProviderCloudflareGoogleIntuition Machines
Invisible mode✅ (always)✅ (v3)✅ (invisible size)
Visual puzzlesNeverv2 only✅ (default)
Privacy✅ (no data selling)❌ (Google data)✅ (GDPR-compliant)
PricingFreeFreeFree tier + paid
Score-based❌ (pass/fail)✅ (v3: 0.0–1.0)❌ (pass/fail)
React packageCommunityCommunity✅ (official)
Accessibility
GDPR-compliant⚠️ (requires consent)
EnterpriseTurnstile ProreCAPTCHA EnterprisehCaptcha Enterprise
Revenue share✅ (site owners earn)
AdoptionGrowing fastMost widely usedMajor sites (Discord)

When to Use Each

Use Turnstile if:

  • Want invisible bot protection with zero user friction
  • Care about user privacy and GDPR compliance
  • Already using Cloudflare for your infrastructure
  • Want a completely free solution with no data trade-offs

Use reCAPTCHA if:

  • Need score-based bot detection (v3 risk scoring)
  • Want the most battle-tested bot detection
  • Already in the Google ecosystem
  • Need to differentiate between "likely human" and "definitely bot"

Use hCaptcha if:

  • Want privacy-focused CAPTCHA with visual challenges
  • Want to earn revenue from CAPTCHA impressions
  • Need strict GDPR compliance
  • Building for users who prefer visible security confirmation

Migration Guide

From reCAPTCHA v2 to Turnstile (invisible upgrade)

The most common migration in 2026 is replacing the "I'm not a robot" reCAPTCHA v2 checkbox with Turnstile's invisible verification. The server-side API is nearly identical — both return a token that you verify with a POST request:

// Before: reCAPTCHA v2 verification
async function verifyRecaptchaV2(token: string): Promise<boolean> {
  const res = await fetch("https://www.google.com/recaptcha/api/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      secret: process.env.RECAPTCHA_SECRET_KEY!,
      response: token,
    }),
  })
  const data = await res.json()
  return data.success === true
}

// After: Turnstile verification (same structure)
async function verifyTurnstile(token: string, ip?: string): Promise<boolean> {
  const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      secret: process.env.TURNSTILE_SECRET_KEY,
      response: token,
      remoteip: ip,
    }),
  })
  const data = await res.json()
  return data.success === true
}

Client-side HTML change:

<!-- Before: reCAPTCHA v2 -->
<script src="https://www.google.com/recaptcha/api.js" async></script>
<div class="g-recaptcha" data-sitekey="RECAPTCHA_SITE_KEY"></div>

<!-- After: Turnstile (invisible, no checkbox) -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async></script>
<div class="cf-turnstile" data-sitekey="TURNSTILE_SITE_KEY"></div>

From reCAPTCHA v3 to Turnstile (removing score dependency)

reCAPTCHA v3's score-based model requires defining thresholds (0.5 is common). Turnstile is pass/fail — simpler but without the nuanced scoring:

// Before: reCAPTCHA v3 with score threshold
const result = await verifyRecaptchaV3(token)
if (!result.success || result.score < 0.5) {
  return res.status(403).json({ error: "Bot detected" })
}

// After: Turnstile pass/fail
const valid = await verifyTurnstile(token, req.ip)
if (!valid) {
  return res.status(403).json({ error: "Bot detected" })
}

If you relied on the reCAPTCHA v3 score to trigger additional verification (e.g., MFA for low-confidence scores), Turnstile doesn't provide this — it's purely pass/fail based on Cloudflare's signals. Consider using Cloudflare's WAF in combination with Turnstile if you need risk tiering.


Accessibility and User Experience

Accessibility is a meaningful differentiator between these CAPTCHA solutions that is often overlooked in integration decisions. Traditional visual CAPTCHA challenges — identifying distorted text, clicking on images matching a description — fail WCAG 2.1 Success Criterion 1.1.1 (non-text content) unless an audio alternative is provided. hCaptcha provides audio challenges as an accessibility alternative, but audio CAPTCHAs are themselves criticized as difficult for users with hearing impairments or in noisy environments. reCAPTCHA v3's invisible scoring and Turnstile's challenge-free model avoid the visual/audio CAPTCHA accessibility problem entirely, since neither presents a challenge to users who pass the background analysis.

For applications with a legally mandated accessibility requirement under Section 508, ADA, or EN 301 549, the invisible challenge models (Turnstile and reCAPTCHA v3) are significantly easier to accommodate. hCaptcha's invisible mode is also available but less commonly documented for accessibility workflows. One nuance: Turnstile's widget renders an invisible iframe that screen readers will encounter — proper aria-hidden placement or a visible fallback label may be needed to avoid confusing screen reader users who tab through the form. The @marsidev/react-turnstile React package handles this with an accessible container element, and the official integration guides include accessibility notes that the CDN snippet alone does not cover.

Rate Limiting and Token Reuse Prevention

Server-side token verification is only half of the bot protection story — token management and replay attack prevention are equally important implementation concerns. Each CAPTCHA token should be verified exactly once and then invalidated. Both Turnstile and reCAPTCHA return a success: false response if a token is submitted a second time to the verification endpoint. Teams should implement this correctly in their server-side verification logic by not caching verification results: every form submission must make a fresh verification request with the token from that submission.

hCaptcha tokens have a 120-second expiry by default, and a token used within that window cannot be reused for a second verification. This means a bot that intercepts a valid token from a legitimate user and submits it simultaneously would need to do so within the same 120-second window — a narrow but non-zero attack surface for man-in-the-browser scenarios. Turnstile tokens expire after 300 seconds, and reCAPTCHA v3 tokens expire after 120 seconds. For high-security forms like password reset or payment confirmation, consider implementing a nonce in the form submission that is tied to the CAPTCHA token server-side — this prevents a valid CAPTCHA token from being replayed against a different form action even within the valid window.

Community Adoption in 2026

Cloudflare Turnstile has grown remarkably since its 2022 launch — from a curiosity to a mainstream CAPTCHA alternative. Cloudflare reports tens of thousands of domains now using Turnstile, and its zero-user-friction model (no visual puzzles, ever) has made it the default recommendation for forms on Cloudflare-hosted applications. For developers already using Cloudflare Pages, Workers, or CDN, Turnstile integrates naturally with the dashboard and the server-side verification endpoint is available in Workers without CORS concerns. The @marsidev/react-turnstile community package provides polished React integration, and the official Next.js integration guide has made adoption straightforward in the Next.js ecosystem.

reCAPTCHA remains the most widely deployed CAPTCHA globally, with hundreds of millions of integrations ranging from simple contact forms to enterprise login systems. Google's bot detection network — leveraging behavioral signals from across the Google ecosystem including Chrome, Search, and Google accounts — gives reCAPTCHA v3's scoring model a depth of signal that Turnstile and hCaptcha cannot match for accuracy on high-value targets. reCAPTCHA Enterprise (a paid tier) adds additional signals and detailed analytics. The data-sharing trade-off (Google uses CAPTCHA interactions for training ML models) is increasingly flagged in GDPR compliance reviews in European projects, which drives developers toward Turnstile and hCaptcha.

hCaptcha is used by Discord, Epic Games, and a growing list of privacy-conscious organizations. Its position is unique: it pays site owners a share of revenue from verified CAPTCHA impressions (paid by companies that use the verification data for ML labeling, with user privacy protected). For sites with significant traffic that require visual challenges, hCaptcha's revenue-sharing model turns a security feature into a minor revenue stream. Discord famously switched from reCAPTCHA to hCaptcha in 2020 citing privacy concerns, and has remained on hCaptcha since. The @hcaptcha/react-hcaptcha official React package maintains parity with the vanilla JS API and is actively maintained.


Methodology

Feature comparison based on Cloudflare Turnstile, Google reCAPTCHA v2/v3, and hCaptcha as of March 2026.

Compare security tooling and frontend libraries on PkgPulse →

See also: AVA vs Jest and Mermaid vs D3.js vs Chart.js 2026, Cerbos vs Permit.io vs OPA (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.