The checkbox is the memorable part of reCAPTCHA, but it is not the security boundary. A bot can post directly to your endpoint and skip every line of browser JavaScript. The decision that matters must happen on your server, after Google verifies the submitted token and your application checks that the response belongs to the site it expected.

Choose a key type before copying code

  • v2 checkbox provides an explicit user challenge and is the clearest integration for this example.

  • v2 invisible runs from an action but still uses a challenge-oriented flow.

  • v3 or score-based keys return risk signals that need action names, thresholds, monitoring, and business policy rather than a universal pass/fail score.

  • reCAPTCHA on Google Cloud is the current management platform; new Classic keys stopped being offered and Classic keys were migrated or scheduled for migration.

  • Existing migrated v2/v3 integrations can continue using siteverify; new projects should review Cloud quotas, billing, ownership, and the recommended assessment API.

Create and restrict the key in Google Cloud

  1. Open the reCAPTCHA page in the Google Cloud console and select an owned project.

  2. Create a website key using the integration type required by the application.

  3. Add the exact production domains and the development domains you genuinely use.

  4. Keep localhost testing separate from production when operational policy requires it.

  5. Record key ownership, quota alerts, billing state, and a rotation contact.

  6. If a framework or plugin requires siteverify, retrieve the legacy secret from the key’s Integration tab; otherwise prefer the current Cloud API documented for the key.

Render the v2 checkbox inside the form

public/contact.htmlhtml
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Contact support</title>
  <script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
  <form method="post" action="/contact">
    <label>
      Email
      <input type="email" name="email" autocomplete="email" required>
    </label>
    <label>
      Message
      <textarea name="message" maxlength="4000" required></textarea>
    </label>
    <div class="g-recaptcha" data-sitekey="YOUR_PUBLIC_SITE_KEY"></div>
    <button type="submit">Send message</button>
  </form>
</body>
</html>

What the browser contributes

  • The HTTPS API script discovers the g-recaptcha element and renders the widget.

  • data-sitekey contains the public site key, not the secret.

  • On submission, the integration adds a g-recaptcha-response form field containing the token.

  • required and maxlength improve UX but are not server-side validation.

  • async defer avoids blocking HTML parsing; a strict Content Security Policy must explicitly allow the required Google origins.

Keep the secret outside source control

.env (local development only)text
RECAPTCHA_SECRET=replace-with-the-server-side-secret
RECAPTCHA_EXPECTED_HOSTNAME=example.com

Treat configuration as deployment state

  • Load .env with the application’s secret manager or environment tooling; do not serve this file.

  • Add local secret files to .gitignore and provide a placeholder-only .env.example.

  • The expected hostname binds an otherwise valid response to your application domain.

  • Use separate keys and secrets for unrelated environments to limit blast radius.

Verify the token from a Node.js backend

server.jsjavascript
import express from "express";
 
const app = express();
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
 
app.post("/contact", async (req, res) => {
  const token = req.body["g-recaptcha-response"];
  if (typeof token !== "string" || token.length === 0) {
    return res.status(400).send("Complete the anti-spam check.");
  }
 
  const params = new URLSearchParams({
    secret: process.env.RECAPTCHA_SECRET ?? "",
    response: token,
  });
 
  let verification;
  try {
    const googleResponse = await fetch(
      "https://www.google.com/recaptcha/api/siteverify",
      {
        method: "POST",
        headers: { "content-type": "application/x-www-form-urlencoded" },
        body: params,
        signal: AbortSignal.timeout(5000),
      },
    );
    if (!googleResponse.ok) throw new Error("verification service failed");
    verification = await googleResponse.json();
  } catch {
    return res.status(503).send("Verification is temporarily unavailable.");
  }
 
  const expectedHost = process.env.RECAPTCHA_EXPECTED_HOSTNAME;
  if (verification.success !== true || verification.hostname !== expectedHost) {
    return res.status(400).send("Verification failed. Please try again.");
  }
 
  const email = String(req.body.email ?? "").trim();
  const message = String(req.body.message ?? "").trim();
  if (!email || !message || message.length > 4000) {
    return res.status(400).send("Check the form fields.");
  }
 
  // Enqueue or store the message here using parameterized APIs.
  return res.status(202).send("Message accepted.");
});
 
app.listen(3001);

The backend owns the decision

  • The handler rejects a missing token before contacting Google.

  • URLSearchParams encodes the documented form fields for siteverify.

  • The secret travels only from your backend to Google over HTTPS.

  • A timeout and exception path fail closed for form acceptance while returning a retryable service response.

  • Checking success alone is weaker than checking the expected hostname as well.

  • The application still validates its own fields after reCAPTCHA; CAPTCHA does not sanitize, authorize, or store data safely.

Understand the verification response

Successful siteverify responsejson
{
  "success": true,
  "challenge_ts": "2026-08-14T10:00:00Z",
  "hostname": "example.com"
}

Fields carry context, not just a boolean

  • success reports whether Google accepted the token verification.

  • challenge_ts identifies when the challenge was loaded; monitor implausible timing when useful.

  • hostname should match the configured web origin expected by this endpoint.

  • Failure responses can include error-codes; log normalized codes for operations without logging the token or secret.

  • Enterprise assessment responses use a different schema and should be validated according to that API.

Token lifetime changes form UX

  • A response token expires after two minutes and is valid for one verification.

  • Do not verify a token in the browser and then attempt to reuse it on the server.

  • If server validation rejects another form field, reset or re-render the widget so the user can obtain a fresh token.

  • Disable accidental double submission, but also make the backend idempotent where duplicate side effects matter.

  • Never cache an accepted token as proof for a later request.

Test without weakening production

  • Use Google’s documented test keys or a dedicated development key rather than sharing the production secret.

  • Test missing, expired, duplicate, malformed, wrong-hostname, network-timeout, and provider-error paths.

  • Verify that the form causes no email, database write, webhook, or account action before CAPTCHA and field validation pass.

  • Exercise keyboard navigation, screen-reader labels, slow networks, script blockers, and a clear retry path.

  • Keep any bypass behind an explicit test environment and make startup fail if that bypass appears in production.

reCAPTCHA is one layer, not the spam strategy

  • Rate-limit by multiple signals while accounting for NATs and accessibility.

  • Use CSRF protection for authenticated or state-changing browser flows.

  • Apply length limits, schema validation, safe output encoding, and parameterized database operations.

  • Use a honeypot or minimum-fill-time signal cautiously as supporting evidence, not an accessibility trap.

  • Queue outbound email and cap downstream work so a burst cannot exhaust the application.

  • Monitor rejection and conversion rates; overly aggressive controls can silently block real people.

Loading reCAPTCHA sends browser data to a third party and is subject to Google’s terms and privacy disclosures. Determine whether your jurisdiction and consent model permit loading it immediately. Offer a usable fallback for people blocked by network policy, accessibility needs, or provider outages, and avoid exposing the user’s full IP unless your chosen API and privacy assessment require it.

Common failures

  • Widget says invalid domain: add the actual hostname to the key configuration and confirm you used the matching site key.

  • `missing-input-secret`: the backend secret is absent; do not substitute the public site key.

  • `invalid-input-response`: the submitted token is malformed, expired, or not valid for this key.

  • `timeout-or-duplicate`: obtain a new token because the old one expired or was already verified.

  • Works in HTML but spam still arrives: the backend is probably not enforcing verification before side effects.

  • Valid token from the wrong site: verify hostname or action context instead of trusting success alone.

  • Sudden quota behavior: inspect the Google Cloud project’s ownership, quotas, billing, alerts, and migration state.

Current Google references