Skip to main content

Turnstile vs reCAPTCHA vs hCaptcha: CAPTCHA and Bot Protection (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.