Captcha

Google reCAPTCHA v3

Protect VitNode sign-up and password reset with Google reCAPTCHA v3 - register a score-based site, add your domains, and wire both keys.

reCAPTCHA v3 never asks the visitor to do anything. It watches the page, scores the request from 0.0 to 1.0, and VitNode's API accepts anything at 0.5 or above. Nothing to install - the provider lives in core - so the whole job is two keys and one config block.

Quick start

If you already have a v3 site key and secret key, this is the entire integration.

.env
RECAPTCHA_SITE_KEY=6Lc...
RECAPTCHA_SECRET_KEY=6Lc...
src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  plugins: [],
  captcha: {
    type: 'recaptcha_v3',
    siteKey: process.env.RECAPTCHA_SITE_KEY,
    secretKey: process.env.RECAPTCHA_SECRET_KEY,
  },
})

Restart the API and /register starts minting a token on submit. There is no widget to look at - a reCAPTCHA badge in the corner of the page is the only visible change.

Do not ship this block without both keys

An empty secretKey does not disable captcha - it breaks it. captchaMiddleware steps aside only when the whole captcha block is missing, so a deployment with the block and no keys rejects registration and password reset with 400. Gate the block on the environment variable:

src/vitnode.api.config.ts
export const vitNodeApiConfig = buildApiConfig({
  captcha: process.env.RECAPTCHA_SECRET_KEY
    ? {
        type: 'recaptcha_v3',
        siteKey: process.env.RECAPTCHA_SITE_KEY,
        secretKey: process.env.RECAPTCHA_SECRET_KEY,
      }
    : undefined,
})

reCAPTCHA v2 is not supported

type accepts 'cloudflare_turnstile' and 'recaptcha_v3', and nothing else. VitNode loads Google's script with render=<siteKey> and mints the token through grecaptcha.execute, which a v2 key cannot do - so a v2 pair in this block ends as a 400, either because no token was minted or because the verification came back without a score. Register a score based (v3) site, or use Cloudflare Turnstile if you want a visible widget.

Register the site

Open the reCAPTCHA admin console

Go to the reCAPTCHA admin console and sign in with the Google account that should own the keys.

Start a new registration

Press the + button in the console's header to register another site.

The Google reCAPTCHA admin console header with the plus button for registering a new site marked with a red arrow

Choose score based (v3) and add your domains

Give the registration a Label - internal, so your site's name will do - and pick Score based (v3) as the reCAPTCHA type.

Then list every domain the form is served from. Google allows first-level subdomains automatically, so your-domain.com also covers www.your-domain.com. For local development you have to add localhost explicitly; it is not implied.

Copy the site key and the secret key

Google shows both on the next screen, and they stay available under the registration's Settings tab.

The site key is public - the API publishes it so the browser can load Google's script. The secret key is not: it only ever travels from your API to Google's siteverify endpoint.

Set the environment variables

These names are not magic - they are whatever you read in your config on the next step. What matters is that both are server-side variables, because the secret key must never reach a browser bundle.

.env
RECAPTCHA_SITE_KEY=6Lc...
RECAPTCHA_SECRET_KEY=6Lc...

Register the provider

Add the captcha block to your API config with type: 'recaptcha_v3'.

src/vitnode.api.config.ts
import { buildApiConfig } from '@vitnode/core/vitnode.config'

export const vitNodeApiConfig = buildApiConfig({
  plugins: [],
  captcha: {
    type: 'recaptcha_v3',
    siteKey: process.env.RECAPTCHA_SITE_KEY,
    secretKey: process.env.RECAPTCHA_SECRET_KEY,
  },
})

The config is evaluated when the process boots, so restart the API rather than waiting for a hot reload to notice.

Verify it works

Ask the API what it thinks it has configured. The site key comes back on the public middleware route:

curl http://localhost:3000/api/@vitnode/core/middleware
# {"isEmail":false,"sso":[],"captcha":{"siteKey":"6Lc...","type":"recaptcha_v3"}}

Then open /register. Google's badge appears in the bottom corner of the page, the Register button is enabled as soon as the script loads, and a completed registration means the score cleared 0.5.

The AdminCP confirms it from the other side: AdminCP → System → Integrations (/admin/core/system/integrations) shows the Captcha card as Active, with reCAPTCHA v3 underneath.

Google's own console is the third view. Under the registration's analytics you should see the request appear, with the score it was given.

What VitNode sends and checks

Useful when the console's numbers and your form's behaviour disagree.

ThingValue
Scripthttps://www.google.com/recaptcha/api.js?hl=<locale>&render=<siteKey>
Token minted bygrecaptcha.execute(siteKey, { action: 'submit' })
Action namesubmit - always, and the API does not verify it
Verify endpointhttps://www.google.com/recaptcha/api/siteverify
Accepted whensuccess === true and score >= 0.5
Threshold0.5, hardcoded in captchaMiddleware
Token travels asthe header x-vitnode-captcha-token

The visitor's IP is forwarded as remoteip, so the score reflects the person filling the form rather than your server. It is read from the first proxy header that has a value - x-forwarded-for first, then x-real-ip, cf-connecting-ip and a dozen more - and falls back to 127.0.0.1 when a request arrives with none of them.

Gotchas

The 0.5 threshold is not configurable

It is a constant in the middleware, not a config option, so you cannot loosen it for a site that keeps scoring low or tighten it for one under attack. If you need a different number, the honest options are a pull request or Cloudflare Turnstile, which has no score in the first place.

A low score looks exactly like a wrong key

Both come back as 400 Captcha validation failed, because the middleware does not pass Google's error-codes through to the response. Check the registration's analytics in the admin console: a request that arrived with a low score is a scoring problem, and no request at all is a key or domain problem.

A rejected submit leaves the button disabled

AutoForm calls onReset() after every submission, which clears isReady - and with v3 there is no widget callback to set it back. A form that stays on screen after a failure ("email already exists") therefore has a dead submit button until it is remounted. Turnstile does not have this problem, because turnstile.reset() makes the widget call back again.

Env changes need a restart

The API config reads process.env when the process starts, so a new key in .env only takes effect after you restart the dev server.

Next