Protecting your web applications from bots and automated attacks is crucial in today's digital landscape. Cloudflare Turnstile offers a user-friendly alternative to traditional CAPTCHAs, providing security without compromising user experience. In this article, we'll walk through the process of integrating Cloudflare Turnstile into a Next.js application.
Source code for this article can be found here. Live demo can be found here.
Prerequisites
- A Cloudflare account with a connected domain
Step 1: Get your credentials
- On the Cloudflare dashboard, navigate to the "Turnstile" section.
- Add a new site by clicking the "Add site" button.
- Fill in your domain details and select the Managed option.
- Select No for pre-clearance
- Click Create and you will receive your site key and secret key. Keep these keys in a secure location.
Step 2: Setup your Next.js app
If you don't already have a Next.js app, you can create one by running npx create-next-app@latest
.
Install the Turnstile React package by running
npm install @marsidev/react-turnstile
Create a .env
file in the root of your project and add your site key and secret key.
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key
TURNSTILE_SECRET_KEY=your_secret_key
Step 3: Create the login page
In our example, we used the root page as the login page, but you can use any page you want.
The example below uses Shadcn components, but you can use any UI library you want.
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Turnstile } from "@marsidev/react-turnstile";
import { useRef, useState } from "react";
import { AlertCircle } from "lucide-react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const [turnstileStatus, setTurnstileStatus] = useState<
"success" | "error" | "expired" | "required"
>("required");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setIsLoading(true);
if (!formRef.current) {
setIsLoading(false);
return;
}
if (turnstileStatus !== "success") {
setError("Please verify you are not a robot");
setIsLoading(false);
return;
}
const formData = new FormData(formRef.current);
const token = formData.get("cf-turnstile-response");
const email = formData.get("email");
const password = formData.get("password");
try {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ token, email, password }),
});
if (response.ok) {
router.push("/success");
} else {
setError("Invalid email or password");
}
} catch (err) {
setError("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to login to your account.
</CardDescription>
</CardHeader>
<form ref={formRef} onSubmit={handleSubmit}>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onError={() => setTurnstileStatus("error")}
onExpire={() => setTurnstileStatus("expired")}
onSuccess={() => {
setTurnstileStatus("success");
setError(null);
}}
/>
{error && (
<div
className="flex items-center gap-2 text-red-500 text-sm mb-2"
aria-live="polite"
>
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}
In the above example, we created a simple login form that sends a POST request to the /api/login
endpoint. Before it can be submitted, the user must pass the Cloudflare Turnstile test. If the user passes the test, we update the turnstileStatus
state to success
. This then allows the user to submit the form and log in to the application. If the user fails the challenge, we display an error message.
Step 4: Handle the login
You might think that using the state to handle the validation is all we need to do, but it's not enough. We also need to handle the server side validation.
If the user sends the login request manually without the Turnstile token, they would essentially be bypassing the security.
To handle this, we need to validate the Turnstile token on the server side. We can use the cf-turnstile-response
token in the request body to validate the user.
Create a app/api/route.ts
file and add the following code.
import { NextRequest, NextResponse } from "next/server";
interface CloudflareTurnstileResponse {
success: boolean;
"error-codes": string[];
challenge_ts: string;
hostname: string;
}
export async function POST(req: NextRequest) {
const { token, email, password } = await req.json();
const turnstileRequest = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: `secret=${encodeURIComponent(process.env.TURNSTILE_SECRET_KEY!)}&response=${encodeURIComponent(token)}`,
headers: {
"content-type": "application/x-www-form-urlencoded",
},
}
);
const turnstileResponse =
(await turnstileRequest.json()) as CloudflareTurnstileResponse;
if (!turnstileResponse.success) {
return NextResponse.json({ message: "Invalid token" }, { status: 400 });
}
// Handle login logic here
return NextResponse.json({ message: "Login successful" });
}
In this example, we parse the token from the request body and validate it using the Cloudflare Turnstile API. If the token is valid, we return a 200 status code and a success message. If the token is invalid, we return a 400 status code and an error message.
Testing
You may notice that the Turnstile test is not working. This is because the hostname is not the same as the one you created in the Cloudflare dashboard.
To fix this, we need to replace the credentials we set in the .env
file with test credentials.
Client tokens
Always passes
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
Always Blocks
NEXT_PUBLIC_TURNSTILE_SITE_KEY=2x00000000000000000000AB
Forces interactive challenge
NEXT_PUBLIC_TURNSTILE_SITE_KEY=3x00000000000000000000FF
Secret tokens
Always passes
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
Always fails
TURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AA
Yields a “token already spent” error
TURNSTILE_SECRET_KEY=3x0000000000000000000000000000000AA
By following these steps, you've successfully integrated Cloudflare Turnstile into your Next.js application. This implementation provides a seamless and secure user experience, protecting your login form from automated attacks without the frustration of traditional CAPTCHAs.