Back to blog
EngineeringSecurityNestJSNextJS

Integrating Cloudflare Turnstile with NestJS and NextJS

CAPTCHAs have long been a necessary safeguard against spam and bot traffic, but they often introduce friction into the user experience. Cloudflare Turnstile offers a modern, privacy-focused, CAPTCHA-free alternative.

AS

Anish Shrestha

Backend Engineer

20th March, 20258 min read

CAPTCHAs have long been the standard safeguard against spam and bot traffic, but the experience for legitimate users has always been a quiet tax: blurry traffic lights, distorted text, awkward delays at the worst possible moment. Cloudflare Turnstile takes a different approach — verify humans invisibly, without making them prove they aren't robots.

In this post we'll wire Turnstile up end-to-end across a NextJS frontend and a NestJS backend, and we'll cover the parts the official docs gloss over: token rotation on form errors, server-side validation via /siteverify, and graceful degradation.

Why Turnstile over reCAPTCHA

  • No user-facing puzzles in the vast majority of cases.
  • Privacy-preserving — no third-party tracking cookies.
  • Free at any scale, with the same anti-abuse signals Cloudflare uses internally.
  • Drop-in replacement for hCaptcha and reCAPTCHA v2/v3.

How it stacks up

In rough order of impact for a typical product site:

  1. Fewer abandoned form submissions on slow connections.
  2. No CMP / cookie banner entries to maintain in the EU.
  3. Zero per-request cost at any traffic volume.
CapabilityTurnstilereCAPTCHA v3
Invisible by defaultYesYes
Cookie-freeYesNo
Free at scaleYesPaid above 1M / mo

Setting up the frontend

On the NextJS side we render the Turnstile widget inside the form and pass the resulting token along with the rest of the payload. The widget is a Client Component because it has to attach to the DOM.

Editor tip

Save the file with + S and let the dev server re-render — you should see the widget appear in place of the placeholder div.

tsx
"use client";
import Script from "next/script";
import { useRef } from "react";

export function ContactForm() {
  const tokenRef = useRef<string | null>(null);

  return (
    <>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async />
      <form onSubmit={async (e) => {
        e.preventDefault();
        await fetch("/api/contact", {
          method: "POST",
          body: JSON.stringify({ token: tokenRef.current }),
        });
      }}>
        <div
          className="cf-turnstile"
          data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
          data-callback={(token: string) => (tokenRef.current = token)}
        />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

Validating on the NestJS side

Every Turnstile token is single-use and tied to the originating IP. Validate it before trusting any data from the request.

ts
@Injectable()
export class TurnstileGuard implements CanActivate {
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const token = req.body?.token;
    if (!token) throw new ForbiddenException("Missing Turnstile token");

    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: process.env.TURNSTILE_SECRET!,
          response: token,
          remoteip: req.ip,
        }),
      },
    );
    const data = await res.json();
    if (!data.success) throw new ForbiddenException("Turnstile failed");
    return true;
  }
}
Figure 1 — The Turnstile verification flow, end-to-end.
“The best security is the kind your users never notice. Turnstile gets us most of the way there without the dark patterns.”
— Anish Shrestha

Wrapping up

Ship the guard, set NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET in your environment, and you're done. A small investment for a meaningfully better first-time-user experience.

TaggedSecurityNestJSNextJS
Share
AS

Written by

Anish Shrestha

Backend Engineer at Asteroid Studio

Ships product across web and mobile at Asteroid. Writes occasionally about the things that took the longest to figure out.

Keep reading

More from the studio.