Captcha
Captcha
Protect registration, password reset, and custom API routes against bots with Cloudflare Turnstile or Google reCAPTCHA v3.
VitNode has built-in bot protection with Cloudflare Turnstile or Google reCAPTCHA v3. Once a provider is configured, every route marked withCaptcha demands a valid token before its handler runs, and AutoForm renders the widget and sends the token for you. Sign-up, password reset and resending the email confirmation link are gated out of the box.
Quick start
1. Configure a provider
Add captcha to apps/api/src/vitnode.api.config.ts. The keys come from your provider. See Cloudflare Turnstile or reCAPTCHA v3.
import { buildApiConfig } from "@vitnode/core/vitnode.config"
export const vitNodeApiConfig = buildApiConfig({
captcha: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY
? {
type: "cloudflare_turnstile",
siteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
secretKey: process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY,
}
: undefined,
})Registration, password reset and the resend-confirmation form are protected straight away:

2. Gate your own route
Add withCaptcha: true to the route definition. This site-notes route takes a message for the site owner:
import { z } from "@hono/zod-openapi"
import { buildRoute } from "@/api/builders"
export const sendContactRoute = buildRoute({
route: {
withCaptcha: true,
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": {
schema: z.object({ message: z.string().trim().min(1).max(2000) }),
},
},
},
},
responses: { 201: { description: "Message sent." } },
},
handler: async (c) => {
// Only reached with a valid captcha token
const { message } = c.req.valid("json")
await c.get("log").debug(`Contact message: ${message}`)
return c.body(null, 201)
},
})Put it in a contact module and add that module to buildApiPlugin in src/config.api.ts, the same way as in API modules. The route is then POST /api/site-notes/contact. Without a token it answers 400 before the handler runs.
3. Render the widget
Pass the app's captcha config to AutoForm. It loads the widget, keeps the submit button disabled until the captcha is ready, and hands onSubmit a fresh token to forward as the fetcher's captchaToken:
import { AutoForm } from "@vitnode/core/components/form/auto-form"
import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"
import { useMiddlewareConfigQuery } from "@vitnode/core/tanstack/auth"
import { fetcher } from "@vitnode/core/tanstack/fetcher"
import { z } from "zod"
import { manifest } from "@/manifest.gen"
const formSchema = z.object({ message: z.string().min(1) })
export const ContactForm = () => {
const { data: config } = useMiddlewareConfigQuery()
return (
<AutoForm
captcha={config.captcha}
fields={[
{
id: "message",
component: (props) => <AutoFormTextarea {...props} label="Message" />,
},
]}
formSchema={formSchema}
onSubmit={async (values, form, { captchaToken }) => {
await fetcher({
plugin: manifest.id,
method: "post",
module: "contact",
path: "/",
captchaToken,
args: { body: values },
})
}}
/>
)
}useMiddlewareConfigQuery suspends until the config loads, so render the form under a Suspense boundary. A real form would also show a toast and reset after a successful send. For a form that is not an AutoForm, see Custom forms.
Supported providers
Cloudflare Turnstile
Privacy-friendly visible widget with dummy test keys for local dev
Google reCAPTCHA v3
Invisible background score verification (0.5 default threshold)