Documentation

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.

apps/api/src/vitnode.api.config.ts
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:

Cloudflare Turnstile widget showing success checkmark on VitNode sign-up form

2. Gate your own route

Add withCaptcha: true to the route definition. This site-notes route takes a message for the site owner:

plugins/site-notes/src/api/modules/contact/send.route.ts
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:

plugins/site-notes/src/pages/contact-form.tsx
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