DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our sign-up button waits for a Cloudflare token, and that token is spent by the submit it goes with

Nakodo finds creators and businesses that suit a brand, emails them on the brand's behalf and introduces the ones who say yes. The Free plan takes no card, and an account starts costing us money the moment it launches a campaign: a crawl of the brand's website, model calls to write the brief, searches. So the sign-up form is the one place where being slightly more expensive for a script than for a person is worth paying for.

Part of that was already done: 121,570 throwaway domains decide our sign-up, and not one byte of the list reaches the browser. The new part is a Cloudflare Turnstile check, which came to 61 lines of server code in src/lib/turnstile.ts and 88 lines of React in src/components/turnstile.tsx.

The whole of it is visible from outside the app:

curl -s https://nakodo.app/login | grep -c challenges.cloudflare.com
# 0
curl -s "https://nakodo.app/login?mode=signup" | grep -c challenges.cloudflare.com
# 1
Enter fullscreen mode Exit fullscreen mode

Signing in has no widget: a password is already a check. Open the sign-up form and the submit button reads "Checking you're not a bot..." until the widget hands it a token.

On only when both keys are set

// The "Nakodo" widget (Cloudflare dashboard > Turnstile), for nakodo.app, its
// subdomains and localhost.
const SITE_KEY = "0x4AAAAAAFbsNoV6M92DDhJg";

const ACTION = "signup";

// What the form draws the widget with when the check is on; null when it's off.
export const turnstileWidget = (): TurnstileWidget | null =>
  SITE_KEY && env("TURNSTILE_SECRET_KEY") ? { siteKey: SITE_KEY, action: ACTION } : null;
Enter fullscreen mode Exit fullscreen mode

The site key is hardcoded because it is public: it is in the HTML of the page you just curled, and you can grep it out of there. The secret is an environment variable, and it is the one that decides whether the feature exists. Without it, turnstileWidget() returns null, the form renders with no widget, and the server-side check returns true:

export async function passedTurnstile(token: FormDataEntryValue | null): Promise<boolean> {
  const secret = env("TURNSTILE_SECRET_KEY");
  if (!turnstileWidget() || !secret) return true;
Enter fullscreen mode Exit fullscreen mode

That is a deliberate hole with exactly one end: a dev machine or a preview deploy can test the sign-up flow without a Cloudflare account, and a deployment that has the secret cannot be talked out of checking. Which state production is in is not a matter of trust, because the curl above answers it.

One answer from Cloudflare, four questions from us

Cloudflare's siteverify call is the easy half:

  if (typeof token !== "string" || token.length === 0 || token.length > 2048) return false;
  const ip = await clientIp();
  let verdict: Verdict;
  try {
    const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ secret, response: token, ...(ip && { remoteip: ip }) }),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) throw new Error(`siteverify ${res.status}`);
    verdict = (await res.json()) as Verdict;
  } catch (e) {
    console.error("Turnstile check failed", e);
    return false;
  }
Enter fullscreen mode Exit fullscreen mode

The token arrives as a FormDataEntryValue, so it can be a File, and a 2KB ceiling means a 5MB form field never becomes a 5MB POST to Cloudflare. clientIp() is the same helper our rate limiter uses, and it is optional in the request body, so a missing IP header is not an error.

Then three more questions, because success: true on its own says less than it looks like it does:

  if (!verdict.success) return false;
  // A pass from Cloudflare's test secret is for example.com with no action:
  // fine on the dev server, never on a deployment.
  if (verdict.metadata?.result_with_testing_key) return !isProductionBuild();
  // The page it was solved on is this deployment's, so a token from a copy of
  // the form elsewhere doesn't count.
  const host = (await headers()).get("host")?.split(":")[0];
  return verdict.action === ACTION && !!host && verdict.hostname === host;
}
Enter fullscreen mode Exit fullscreen mode

Cloudflare publishes test keys that always pass. They are genuinely useful while writing the form, and they are a catastrophe if a real deployment is ever configured with one by accident, so the verdict's own metadata.result_with_testing_key flag is read and refused outside development. It costs one line and removes a class of misconfiguration that would otherwise look exactly like a working check.

action is the string the widget was rendered with. Our widget is registered for nakodo.app, its subdomains and localhost, so a token solved on a preview deployment is a valid token for this site key. Comparing verdict.hostname with the request's own host is what stops it from being spendable here.

And every failure mode, including a Cloudflare outage, returns false. That is the opposite choice from the rate limiter, which lets you through when its own store is unreachable. The asymmetry is the point: a rate limiter that fails closed locks out paying customers, while a bot check that fails open hands out accounts. What it costs is that if Cloudflare is down, nobody can create an account for as long as it is down, and the form has to say so in a way that does not read as "your details are wrong".

A token is spent by one submit, which is a UI problem

Turnstile tokens are single-use. The form can submit more than once: a duplicate email address, a password under eight characters, a server error. After any of those the old token is spent and the next submit needs a fresh one, which the widget will only mint if you ask it to reset.

  const lastSubmit = useRef(submitted);

  useEffect(() => {
    if (submitted === lastSubmit.current) return;
    lastSubmit.current = submitted;
    if (widget.current !== null) window.turnstile?.reset(widget.current);
    onPass(false);
  }, [submitted, onPass]);
Enter fullscreen mode Exit fullscreen mode

submitted is not a counter. It is the state object out of useActionState, passed straight in:

{check && <Turnstile siteKey={check.siteKey} action={check.action} submitted={state} onPass={setHuman} />}
Enter fullscreen mode Exit fullscreen mode

React gives a new object for every result the action returns, so identity comparison means "a new answer came back from the server", with no flag to increment and nothing to reset when the component mounts: lastSubmit starts as the current value, so the first render never resets a widget that has just drawn itself.

The button is disabled until the widget says a token is in the form:

<Button type="submit" size="lg" className="w-full" disabled={pending || !human}>
  {pending ? "One moment..." : !human ? "Checking you're not a bot..." : mode === "signin" ? "Sign in" : "Create account"}
</Button>
Enter fullscreen mode Exit fullscreen mode

With the check off, useState(!check) starts that as true and the button behaves as it always did.

The failure the widget cannot report to our server

The interesting bug in a "wait for the token" design is the case where the token never comes, because challenges.cloudflare.com is blocked by an extension or a corporate proxy. There is nothing to verify, nothing to time out server-side, and the button would read "Checking you're not a bot..." for ever, which is the most insulting possible way to fail at that sentence.

So the error callback is a state change, not a log line:

      "expired-callback": () => onPass(false),
      "error-callback": () => {
        setFailed(true);
        onPass(false);
      },
Enter fullscreen mode Exit fullscreen mode
{failed && (
  <p role="alert" className="text-sm text-danger">
    Cloudflare&apos;s check couldn&apos;t run in this browser. Reload the page, and if a blocker is on, allow
    challenges.cloudflare.com.
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

Naming the host in the message is deliberate: the person who needs that sentence is the person with a blocker, and "allow challenges.cloudflare.com" is an instruction they can act on, while "something went wrong" is not. The <Script> tag's own onError sets the same flag, for the case where the script never loads at all rather than loading and failing.

One more small thing, which is the sort of detail that only shows up once real people use the form:

{/* The widget's own height, so the button below doesn't jump when it appears. */}
<div ref={box} className={failed ? undefined : "min-h-[65px]"} />
Enter fullscreen mode Exit fullscreen mode

The widget is rendered explicitly (api.js?render=explicit) rather than by class name, so it appears a moment after the form does. Reserving its height means the button is not moving under the cursor on its way down the page, and dropping the reservation when the widget has failed means the error message does not sit in a 65px hole.

What it does not do

It is a check on account creation, not a wall in front of the app. Sign-in does not have it, the confirmation email has its own limits, and everything behind the login has an account attached to it. The only thing this guards is the one form where a stranger can create state in our database for free, and the measure of whether it works is not a dashboard number: it is whether the people who should get through, do.

The form is live, and the Free plan still takes no card.

Top comments (0)