Portreeve
how-to · 7 Sept 202613 min read

How to add Cloudflare Turnstile to a signup form (and what it won't stop)

Add Cloudflare Turnstile to a signup form in an hour: widget modes, React render, siteverify in Node, the error codes, and the fake signups it cannot see.

Cloudflare Turnstile takes about an hour to put on a signup form. When you are done you will have a widget on the client, a token verified server-side before any account row is created, error codes handled well enough that a real user with a stale tab gets a sensible message instead of a 500, and a test path you can walk without touching production keys. It will kill the curl loop hammering your /api/signup endpoint, and it will not touch the person who has already made ten accounts by hand.

The integration first, then the gap.

Prerequisites: a Cloudflare account (free tier is fine, and your domain does not need to be on Cloudflare's DNS), Node 18.17 or later, and a signup form you control on both the client and the server. Code below is Next.js App Router with a route handler, but the server half is plain fetch and moves to Express or Hono unchanged.

What Turnstile is actually checking

Turnstile does not ask a human to prove anything. It runs code in the browser and grades the environment. Cloudflare's general availability post is unusually direct about the mechanism: "We find and stop bots by running a series of in-browser tests, checking browser characteristics, native browser APIs, and asking the browser to pass lightweight tests (ex: proof-of-work tests, proof-of-space tests) to prove that it's an actual browser."

On the checkbox variant, the same post adds that "the actual act of checking a box isn't important, it's the background data we're analyzing while the box is checked that matters."

So the token certifies exactly one thing: something that behaved like a real browser, on a real machine, ran real JavaScript and paid a small compute cost. It says nothing about who is driving that browser, or how many accounts they already have.

Step 1: create the widget and pick a mode

In the Cloudflare dashboard, go to Turnstile and click Add widget. Three fields matter: a widget name, a hostname list, and a widget mode.

The hostname list is your first real control. Add only the domains that should be able to mint tokens for this sitekey. Your sitekey is public by design and sits in the page source; hostname scoping is what stops someone from embedding it on their own host and farming tokens. siteverify returns the hostname the challenge was served on, and you should check it yourself as well.

The three widget modes:

Managed "automatically chooses between non-interactive or checkbox challenge based on visitor risk level." This is the default and it is the right one for a signup form.

Non-interactive shows a visible widget with a loading spinner and runs the challenge without asking for a click. Visitors "will never be required or prompted to interact with the widget."

Invisible runs with no widget and no loading indicator at all. Good for conversion, and Cloudflare makes referencing the Turnstile Privacy Addendum in your own privacy policy a condition of using it.

Copy the sitekey (0x4...) and the secret key. Sitekey goes in your client bundle. Secret goes in the server environment and nowhere else.

Step 2: render the Turnstile widget in React and Next.js

Implicit rendering means adding <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer> and a <div class="cf-turnstile" data-sitekey="..."> inside your <form>. Cloudflare then injects a hidden input named cf-turnstile-response and a normal form POST carries the token for free. If your signup is a server-rendered HTML form, stop here, that is the whole client side.

React forms need explicit rendering, because the container element does not exist when the script parses. Add ?render=explicit to the script URL and call turnstile.render() yourself:

"use client";
import { useEffect, useRef, useState } from "react";

export function SignupForm() {
  const container = useRef<HTMLDivElement>(null);
  const widgetId = useRef<string | undefined>(undefined);
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    const script = document.createElement("script");
    script.src =
      "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      widgetId.current = window.turnstile.render(container.current, {
        sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
        action: "signup",
        callback: (t: string) => setToken(t),
        "expired-callback": () => setToken(null),
        "error-callback": () => setToken(null),
      });
    };
    document.head.appendChild(script);
    return () => {
      if (widgetId.current) window.turnstile?.remove(widgetId.current);
    };
  }, []);

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ email: data.get("email"), turnstileToken: token }),
    });
    if (!res.ok) {
      window.turnstile?.reset(widgetId.current);
      setToken(null);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <div ref={container} />
      <button type="submit" disabled={!token}>Create account</button>
    </form>
  );
}

Three of those options are load-bearing.

expired-callback matters because a token is valid for 300 seconds from generation. A user who opens your signup page, goes to find a password manager, and comes back nine minutes later will submit a dead token unless you clear it and re-render.

The reset() on a failed response matters because each token can be validated exactly once. If your server rejects the signup for any reason and the user fixes their email and resubmits, the second attempt replays a spent token and siteverify returns timeout-or-duplicate. Now a legitimate user is stuck in a loop that looks like your form is broken.

action: "signup" costs nothing and gives you a field on the verify response you can assert against, which catches a token minted on your login widget and replayed at signup.

On Next.js, that effect re-runs on its own when the component unmounts between routes. If the form lives in a layout that survives client-side navigation, add usePathname() to the dependency array so the widget is rebuilt when the route changes.

Step 3: verify the token with siteverify

The token is worthless until Cloudflare confirms it. Server-side validation is a POST to https://challenges.cloudflare.com/turnstile/v0/siteverify with secret and response, plus two optional fields you should send.

// app/api/signup/route.ts
import { randomUUID } from "node:crypto";

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

const USER_FACING = new Set([
  "timeout-or-duplicate",
  "invalid-input-response",
  "missing-input-response",
]);

export async function POST(req: Request) {
  const { email, turnstileToken } = await req.json();
  const ip =
    req.headers.get("cf-connecting-ip") ??
    req.headers.get("x-forwarded-for")?.split(",")[0].trim();

  const res = await fetch(SITEVERIFY, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      secret: process.env.TURNSTILE_SECRET_KEY,
      response: turnstileToken,
      remoteip: ip,
      idempotency_key: randomUUID(),
    }),
  });

  const outcome = await res.json();

  if (!outcome.success || outcome.action !== "signup") {
    const codes: string[] = outcome["error-codes"] ?? [];
    if (!codes.some((c) => USER_FACING.has(c))) {
      console.error("turnstile misconfigured", codes); // page yourself
    }
    return Response.json(
      { error: "That took a moment too long. Try again." },
      { status: 400 },
    );
  }

  // create the account
}

remoteip lets Cloudflare compare the IP that solved the challenge against the IP submitting it, which catches the crudest form of token relay. Send the IP your edge actually gives you: cf-connecting-ip if you are behind Cloudflare, otherwise the first entry in x-forwarded-for, and only if you trust the proxy that set it.

idempotency_key is a UUID you generate. If your fetch times out and you retry, the same key returns the same verdict instead of burning the token. It is the same idea as a Stripe idempotency key, applied to a much cheaper operation.

The response gives you success, challenge_ts, hostname, action, cdata, and error-codes. In production, assert on hostname too. A sitekey scoped to three domains still lets a fourth domain's token verify if you never look.

Step 4: handle the error codes like a real form

error-codes is an array. Only three of them will ever reach a real user, and the rest are your bugs.

CodeCauseWhat the user should see
timeout-or-duplicateToken already validated once, or the solve timed out"That took a moment too long. Try again." Reset the widget.
invalid-input-responseToken invalid, malformed, or expiredSame message. Reset the widget.
missing-input-responseClient sent nothingSame, and log it: usually your JS failed to load
missing-input-secretYou did not send secretNothing. Alert yourself.
invalid-input-secretSecret key invalid or expiredNothing. Alert yourself. A test secret against a live sitekey does this.
bad-requestMalformed POSTNothing. Alert yourself.
internal-errorCloudflare-side failureRetry once with the same idempotency_key

The rule for the first three: one message, always the same, always with a widget reset. Do not tell the user which code fired. The rule for the rest: never show them, page yourself, because every one of them means signups are failing for everybody.

Decide now what happens when siteverify itself is unreachable. Fail closed and a Cloudflare incident becomes your signup outage. Fail open and an attacker who can drop that one request bypasses the wall entirely. For most small SaaS the honest answer is fail open on network error but flag the signup for review, because a few unverified signups are cheaper than a dead funnel.

Step 5: fire a test signup

Cloudflare publishes dummy keys that work on any domain, including localhost. Use them to walk every branch before you touch live keys.

1x00000000000000000000AA is a visible sitekey that always passes. 2x00000000000000000000AB always fails. 3x00000000000000000000FF forces the interactive challenge, which is how you see what a suspicious visitor gets without having to look suspicious.

On the secret side, 1x0000000000000000000000000000000AA always passes validation, 2x0000000000000000000000000000000AA always fails, and 3x0000000000000000000000000000000AA returns the token-already-spent error. That last one is the important test: run a signup against it and confirm your form shows the retry message and re-renders the widget rather than throwing. Note that the dummy token only verifies against a test secret; a production secret rejects it.

Then swap in live keys, submit a real signup, and check the widget's analytics page in the Cloudflare dashboard. You should see the solve count tick up and match your signup count. If solves exceed signups by a lot, someone is loading your form and not submitting, which is itself a signal worth watching.

What still gets through

Turnstile stops a script that never runs a browser. That is a real and large category: the curl loop, the Python requests script, the headless Chrome with the wrong navigator properties. If your fake signups are coming from one of those, you are done and the rest of this post is optional.

It does not stop a real browser. It cannot, because the token only certifies that a browser environment did some work, and a farm of real browsers on residential IPs produces exactly that.

The economics are public and unflattering. In a December 2023 Ask HN thread on protecting a SaaS from bots, jay-barronville reports checking the solver services rather than taking their word for it: "I found some web scraping-related services on the internet that claim to easily and programmatically bypass Turnstile for literal pennies, but I didn't 100% believe them, so I tested the services. <20 minutes later, I had a script running bypassing challenge after challenge for <$1.00." Nearly three years later, in a thread on Cloudflare's challenge page redesign, noplacelikehome describes it the same way: "a gate that can be trivially bypassed with solvarr or similar."

And none of that applies to the most common case anyway, which is one person, one browser, ten disposable addresses, working through your free tier by hand. Every one of those signups passes Turnstile honestly.

The reflex, once fake signups keep arriving, is to escalate: force the interactive checkbox, add a second challenge, make it harder. That trade is bad on both ends. You pay in real conversions from real users on VPNs, privacy browsers, and slow devices, and you gain nothing against the two attacks that were hurting you, because a solver farm and a determined human both pass an interactive challenge. Turnstile is the cheap outer wall. Building it taller does not make it a different wall.

The other reflex, and the one that Ask HN thread keeps landing on, is to require a card up front. That is defensible if every trial burns GPU minutes. For most products it trades the entire top of your funnel against a filter that the funded attacker already clears, because anyone running card testing against your checkout has thousands of cards to spend.

Pair it with identity signals

The layer Turnstile structurally cannot provide is memory. It grades one browser session in isolation and has no opinion about whether this is the eleventh account from the same device this week, whether the address is a five-minute mailbox, or whether the card that will hit checkout in an hour already sits on a chargebacked account. Those are the signals that separate a human abuser from a human customer, and they are all things you know or can know at the moment of signup.

Concretely: a per-device counter and a per-IP-range counter over a rolling window (velocity checks are the cheapest useful thing you can build), a check against disposable email domains, and a stable device identifier so that "same person, new email" collapses back into one record.

This is the layer I build. After the Turnstile token verifies, the same handler can call Portreeve on the signup event with ip, email, and a device token from @portreeve/browser, and get back allow, review, or block with reason codes. review never blocks the user: the signup completes, the event lands in a queue, and a later deny arrives on a signed webhook so you can revoke, which means the engine can flag hard without you eating false positives on the form. The quickstart is a single API call, the device fingerprinting page covers the browser snippet, and handling verdicts covers what to do with each one.

Whatever you use for the second layer, keep the shape: Turnstile first, cheap and silent, rejecting anything that never ran a browser. Identity second, on the requests that survive.

Going to production

Before you ship, confirm the secret key is not in any file that reaches the client, because a leaked Turnstile secret lets anyone verify their own tokens. Scope the widget hostnames to production domains, and cut a second widget for staging rather than adding localhost to the live one.

Then three things on the server. Assert hostname and action on every verify response. Log error-codes with your request ID so you can tell a broken deploy from a solver run. And record the Turnstile outcome on the user row, because in three weeks, when you are reconstructing an abuse cluster, knowing which accounts sailed through and which ones needed the interactive challenge is genuinely useful.

The mistake to avoid is treating the widget as finished work. Add it, verify the token properly, then watch your signup metrics for a week. If fake accounts keep arriving at the same rate, that is your answer about which attack you had, and the fix is not a harder challenge.

If you want the identity layer without building the counters yourself, Portreeve's free tier covers 1,000 screened events a month with no card: create an account.

← Back to all posts