Next Starter Logo
Security

reCAPTCHA v3

Next Starter integrates Google reCAPTCHA v3 to protect sign-in, registration, and contact forms from bots, with server-side score verification and no user-facing challenges.

Overview

Next Starter uses Google reCAPTCHA v3 to protect forms from bots. Unlike v2, reCAPTCHA v3 runs entirely in the background: users never see a challenge. It returns a score (0.0 to 1.0) indicating how likely the interaction is to be human. The server-side action in app/actions/recaptcha.ts verifies the token and rejects low-confidence requests in production.

The integration uses the next-recaptcha-v3 package, which provides a React context and hook built for Next.js.

Environment Variables

NEXT_PUBLIC_RECAPTCHA_SITE_KEY="6Le..."
RECAPTCHA_SECRET_KEY="6Le..."

Both variables are optional. When RECAPTCHA_SECRET_KEY is absent, the server-side verification function returns true in development and false in production (forcing requests through in local environments without breaking the flow). NEXT_PUBLIC_RECAPTCHA_SITE_KEY controls whether the reCAPTCHA script loads on the client. Without it, token generation will fail.

Obtain both keys from the Google reCAPTCHA admin console. Register your site with the reCAPTCHA v3 type and add your production domain.

Server-Side Verification

The verification logic lives in app/actions/recaptcha.ts as a Server Action:

app/actions/recaptcha.ts
"use server";

import { after } from "next/server";
import { logger } from "@/lib/logger";

export async function validateRecaptcha(token: string): Promise<boolean> {
  try {
    if (!process.env.RECAPTCHA_SECRET_KEY) {
      after(() => {
        logger.error(
          { event: "recaptcha_missing_secret" },
          "RECAPTCHA_SECRET_KEY not configured",
        );
      });
      return process.env.NODE_ENV !== "production";
    }

    const response = await fetch(
      "https://www.google.com/recaptcha/api/siteverify",
      {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
      },
    );

    const result = await response.json();

    if (!result.success) {
      if (process.env.NODE_ENV === "production") {
        return false;
      }
      return true;
    }

    if (result.score !== undefined) {
      const scoreThreshold = 0.5;
      if (result.score < scoreThreshold) {
        if (process.env.NODE_ENV === "production") {
          return false;
        }
        return true;
      }
    }

    return true;
  } catch (error) {
    after(() => {
      logger.error(
        { event: "recaptcha_verification_error", err: error },
        "reCAPTCHA verification failed",
      );
    });
    return process.env.NODE_ENV !== "production";
  }
}

Score Threshold

The default threshold is 0.5. Scores above this are treated as human; scores below are treated as bot traffic and rejected in production. You can adjust this value to be more or less strict:

  • 0.7 or higher: more aggressive bot filtering, may occasionally block legitimate users
  • 0.3 or lower: more permissive, useful if your users tend to interact in unusual patterns

Logging

Errors are logged using after() from next/server, which runs the logging callback after the response is sent. This avoids adding latency to the user-facing request in the error path.

Client-Side Token Generation

Wrap any form component in ReCaptchaProvider, then use the useReCaptcha hook to execute the challenge before submission.

Provider Setup

import { ReCaptchaProvider } from "next-recaptcha-v3";

export function SignInForm() {
  return (
    <ReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!}>
      <SignInFormInner />
    </ReCaptchaProvider>
  );
}

The provider loads the reCAPTCHA script and makes the site key available to all child components. Wrap only the form component rather than a top-level layout to avoid loading the script on every page.

Executing the Challenge

Inside the wrapped component, call executeRecaptcha with an action name before submitting the form:

"use client";

import { useReCaptcha } from "next-recaptcha-v3";
import { validateRecaptcha } from "@/app/actions/recaptcha";

function SignInFormInner() {
  const { executeRecaptcha } = useReCaptcha();

  const handleSubmit = async (values: FormValues) => {
    const token = await executeRecaptcha("login");

    if (!token) {
      // token generation failed
      return;
    }

    const isHuman = await validateRecaptcha(token);
    if (!isHuman) {
      // reject the submission
      return;
    }

    // proceed with the actual form action
    await signIn.email({ email: values.email, password: values.password });
  };
}

The action name passed to executeRecaptcha (e.g., "login", "register", "contact_form") appears in the reCAPTCHA admin console and helps you identify which forms are being targeted. Use a descriptive, lowercase string without spaces.

Protecting a Form

The pattern used across all protected forms in Next Starter is:

  1. Wrap the form in ReCaptchaProvider (exported from the parent component)
  2. Call executeRecaptcha(actionName) at the start of the submit handler
  3. Pass the token to validateRecaptcha before running any business logic
  4. Return early with a user-facing error if verification fails

The sign-in, register, forgot password, and contact forms all follow this pattern.

Adding reCAPTCHA to a New Form

The contact form shows the recommended pattern. The token is appended to the FormData payload and verified inside the server action, which keeps verification server-side and avoids an extra round-trip:

app/(site)/contact/contact-form.tsx
"use client";

import { ReCaptchaProvider, useReCaptcha } from "next-recaptcha-v3";
import { submitContactForm } from "@/app/actions/contact";

function ContactFormInner() {
  const { executeRecaptcha } = useReCaptcha();
  const [error, setError] = useState("");

  const handleSubmit = async (values: ContactFormValues) => {
    const token = await executeRecaptcha("contact_form");

    if (!token) {
      setError("reCAPTCHA verification failed. Please refresh and try again.");
      return;
    }

    const formData = new FormData();
    formData.append("name", values.name);
    formData.append("email", values.email);
    formData.append("message", values.message);
    formData.append("recaptcha_token", token);

    const response = await submitContactForm(formData);

    if (!response.success) {
      setError(response.error || "Failed to send message");
    }
  };

  return <form onSubmit={form.handleSubmit(handleSubmit)}>{/* ... */}</form>;
}

export function ContactForm() {
  return (
    <ReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!}>
      <ContactFormInner />
    </ReCaptchaProvider>
  );
}

The server action (app/actions/contact.ts) reads recaptcha_token from the FormData and calls validateRecaptcha internally before processing the submission.

CSP Configuration

The reCAPTCHA widget loads scripts and renders an iframe from Google's domains. The Content Security Policy in next.config.ts already includes the necessary allowances:

`script-src 'self' 'unsafe-inline' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/`,
`connect-src 'self' https://www.google.com/recaptcha/ ...`,
`frame-src 'self' https://www.google.com/recaptcha/`,

No CSP changes are needed when adding reCAPTCHA to a new form on the same site.

Testing in Development

When RECAPTCHA_SECRET_KEY is not set:

  • The validateRecaptcha function returns true automatically in development
  • Forms work normally without any reCAPTCHA interaction
  • An error is logged via Pino when validateRecaptcha is called without a secret key configured

Note: NEXT_PUBLIC_RECAPTCHA_SITE_KEY controls whether the reCAPTCHA script loads on the client. If it is absent, token generation silently fails, but the server-side bypass still applies when RECAPTCHA_SECRET_KEY is also unset.

To test the full reCAPTCHA flow locally, set both keys in your .env file. Add localhost to your allowed domains in the reCAPTCHA admin console. Google does not accept localhost as an origin automatically.

To verify that bot rejection works correctly, you can temporarily raise the score threshold to 1.1 (which will never be reached) and confirm that the form rejects submissions in production mode.

On this page