Captcha verification failed

I keep getting "Captcha verification failed" after adding Cloudflare Turnstyle captcha using Nextjs:
import { Footer } from "@/components/internal/footer";
import RegisterForm from "@/components/internal/forms/register-form";
import Navigation from "@/components/internal/navigation";
import { Turnstile } from "@marsidev/react-turnstile";

export default function Login() {
return (
<div className="flex flex-col w-full h-full min-h-screen">

<Navigation />
<div className="flex flex-col gap-4 items-center justify-center flex-grow h-full w-full max-w-[90rem] mx-auto">
<RegisterForm />
<Turnstile siteKey={process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string} />

</div>
<Footer />
</div>
);
}
import { Footer } from "@/components/internal/footer";
import RegisterForm from "@/components/internal/forms/register-form";
import Navigation from "@/components/internal/navigation";
import { Turnstile } from "@marsidev/react-turnstile";

export default function Login() {
return (
<div className="flex flex-col w-full h-full min-h-screen">

<Navigation />
<div className="flex flex-col gap-4 items-center justify-center flex-grow h-full w-full max-w-[90rem] mx-auto">
<RegisterForm />
<Turnstile siteKey={process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string} />

</div>
<Footer />
</div>
);
}
4 Replies
Alex
AlexOP5mo ago
In RegisterForm:
const onSubmit = async (data: RegisterFormValues) => {
setError("");
setIsLoading(true);

try {
// TODO: Implement register
await authClient.signUp.email({
email: data.email as string,
password: data.password as string,
name: data.email as string,
callbackURL: "/app",
fetchOptions: {
headers: {
"x-captcha-response": process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string
},
onError: (ctx) => {
if (ctx.error.status === 422) {
setError("Something went wrong.");
} else {
console.log(ctx);
setError(ctx.error.message);
}
},
}
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
setError("Something went wrong. Please try again.");
} finally {
setIsLoading(false);
}
};
const onSubmit = async (data: RegisterFormValues) => {
setError("");
setIsLoading(true);

try {
// TODO: Implement register
await authClient.signUp.email({
email: data.email as string,
password: data.password as string,
name: data.email as string,
callbackURL: "/app",
fetchOptions: {
headers: {
"x-captcha-response": process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string
},
onError: (ctx) => {
if (ctx.error.status === 422) {
setError("Something went wrong.");
} else {
console.log(ctx);
setError(ctx.error.message);
}
},
}
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (err) {
setError("Something went wrong. Please try again.");
} finally {
setIsLoading(false);
}
};
In lib/auth.ts:
import { PrismaClient } from "@/lib/generated/prisma";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { captcha } from "better-auth/plugins";

const prisma = new PrismaClient();

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
trustedOrigins: ["http://localhost"],
emailAndPassword: {
requireEmailVerification: true,
enabled: true,
minPasswordLength: 8,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
sendResetPassword: async ({ user, url, token }, request) => {
// todo
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET! as string,
},
},
plugins: [
captcha({
provider: "cloudflare-turnstile",
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY! as string,
endpoints: ["/sign-up/email", "/forget-password"],
}),
],
});
import { PrismaClient } from "@/lib/generated/prisma";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { captcha } from "better-auth/plugins";

const prisma = new PrismaClient();

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
trustedOrigins: ["http://localhost"],
emailAndPassword: {
requireEmailVerification: true,
enabled: true,
minPasswordLength: 8,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
sendResetPassword: async ({ user, url, token }, request) => {
// todo
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET! as string,
},
},
plugins: [
captcha({
provider: "cloudflare-turnstile",
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY! as string,
endpoints: ["/sign-up/email", "/forget-password"],
}),
],
});
lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
/** The base URL of the server (optional if you're using the same domain) */
baseURL: "http://localhost:3000",
});
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
/** The base URL of the server (optional if you're using the same domain) */
baseURL: "http://localhost:3000",
});
Envs are defined like so in both .env.local and regular .env file : CLOUDFLARE_TURNSTILE_SECRET_KEY=..... NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=...... It is basically throwing a 403: POST /api/auth/sign-up/email 403 in 145ms
bc 🐧🪺
bc 🐧🪺4mo ago
Hey! Same here, very similar setup (Next.js etc). I'll let you know if I solve it
ChowderCrab
ChowderCrab4mo ago
fetchOptions: {
headers: {
"x-captcha-response": process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string
},
fetchOptions: {
headers: {
"x-captcha-response": process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string
},
This is the problem area. The x-captcha-response needs to be set to the value that the Turnstile widget gets back from Cloudflare. I think there are a couple ways of using react-turnstile, but I do it with the onSuccess callback. Your siteKey is fine but you can add onSuccess.
<Turnstile
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY!}
onSuccess={setTurnstileToken}
/>
<Turnstile
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY!}
onSuccess={setTurnstileToken}
/>
setTurnstileToken is pointing back to state defined with useState in the component. In your case you would pass turnstileToken to your RegisterForm and then use that string as the value for x-captcha-response. Alternately, add the Tunstile component right within your register form instead and then handle the state internally in the form component.
I also added form validation to confirm that "turnstileToken" has a value if the user submits before the automated check from Turnstile is complete (or if it decides they need to interactively click and they haven't yet). So we block on the client until the token has been set. To summarize how it works: when the Cloudflare widget runs it confirms the user is not a bot and then gives you a token. You then pass that token to your server via the headers. The Better Auth plugin will then call Cloudflare's siteverify endpoint from the server to check the validity of the token it got from the client. CF will confirm it's a valid token and your server will then proceed, knowing that it's a real user.
bc 🐧🪺
bc 🐧🪺4mo ago
Great digging! Thanks Erik, will explore this later this week. Appreciate your time + thorough writeup

Did you find this page helpful?