TruForms supports two captcha providers: Cloudflare Turnstile (free, no user puzzle, recommended) and hCaptcha (free tier with a visual challenge). Both work the same way — you paste your site key and secret key into the dashboard, drop a widget into your form, and TruForms verifies the token server-side before accepting the submission.
Do you even need this? Probably not on day one. Every form already ships with a honeypot field, per-IP and per-form rate limits, and spam scoring. Reach for captcha only when a specific form is being actively targeted — see When you need it below.
When you need it
Most forms don't. Our default protection — honeypot field, per-IP and per-form rate limits, and heuristic spam scoring — handles the long tail of low-effort bots. Reach for captcha when:
- A specific form is being targeted by a determined attacker.
- You need a visible "I'm not a robot" signal for compliance reasons.
- You're getting submissions from headless browsers that bypass the honeypot.
Provider comparison
| Provider | User experience | Free tier | Best for |
|---|---|---|---|
| Cloudflare Turnstile | Invisible most of the time | Unlimited | Default choice. Works without a Cloudflare account on the site. |
| hCaptcha | Visible checkbox + challenge | 1M requests/month, free | When you want a visible signal or already use hCaptcha elsewhere. |
Step 1 — Get your keys
Turnstile
- Go to Cloudflare dashboard → Turnstile.
- Click Add site, give it a name, and add the domains where your form lives.
- Choose widget mode Managed (recommended) or Invisible.
- Copy the Site key (starts with
0x4AAAA…) and Secret key (starts with0x4AAAA…, longer).
hCaptcha
- Sign up at hcaptcha.com.
- Add a site, copy the Site key (UUID format) and Secret key (
0x…).
Step 2 — Configure the form
In the TruForms dashboard:
- Open Forms → your form → Settings → Security.
- Set Captcha to Cloudflare Turnstile or hCaptcha.
- Paste the Site key (public — safe to expose in HTML).
- Paste the Secret key (encrypted at rest with AES-256-GCM; never returned by the API).
- Click Save.
You can rotate or remove the secret at any time. Setting captcha back to None disables verification immediately.
Step 3 — Add the widget to your form
HTML — Cloudflare Turnstile
<form action="https://forms.truenotech.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- Turnstile widget -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
Cloudflare auto-injects a hidden <input name="cf-turnstile-response"> containing the token. The browser POSTs it with the rest of the form — no extra JavaScript required.
HTML — hCaptcha
<form action="https://forms.truenotech.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- hCaptcha widget -->
<script src="https://hcaptcha.com/1/api.js" async defer></script>
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
hCaptcha auto-injects <input name="h-captcha-response">.
React — Cloudflare Turnstile
Install the official React wrapper: npm install @marsidev/react-turnstile
import { useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
export function ContactForm() {
const [token, setToken] = useState(null);
async function onSubmit(e) {
e.preventDefault();
if (!token) return alert('Please complete the captcha');
const data = new FormData(e.currentTarget);
data.append('access_key', 'YOUR_ACCESS_KEY');
data.append('cf-turnstile-response', token);
const res = await fetch('https://forms.truenotech.com/api/submit', {
method: 'POST',
body: data,
});
const json = await res.json();
console.log(json);
}
return (
<form onSubmit={onSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile siteKey="YOUR_SITE_KEY" onSuccess={setToken} />
<button type="submit" disabled={!token}>
Send
</button>
</form>
);
}
Vue — hCaptcha
Install: npm install @hcaptcha/vue3-hcaptcha
<script setup>
import { ref } from 'vue';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
const token = ref(null);
async function onSubmit(e) {
e.preventDefault();
if (!token.value) return;
const form = e.target;
const data = new FormData(form);
data.append('access_key', 'YOUR_ACCESS_KEY');
data.append('h-captcha-response', token.value);
await fetch('https://forms.truenotech.com/api/submit', {
method: 'POST',
body: data,
});
}
</script>
<template>
<form @submit="onSubmit">
<input name="email" type="email" required />
<textarea name="message" required />
<VueHcaptcha sitekey="YOUR_SITE_KEY" @verify="(t) => (token = t)" />
<button type="submit" :disabled="!token">Send</button>
</form>
</template>
fetch — JSON payload
If you're posting JSON instead of FormData, send the token under any of the accepted field names:
await fetch('https://forms.truenotech.com/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_key: 'YOUR_ACCESS_KEY',
name: 'Aanya Sharma',
email: '[email protected]',
message: 'Hello!',
'cf-turnstile-response': token, // or 'h-captcha-response', or 'captcha_token'
}),
});
Accepted token field names
We look for the captcha token under any of these keys, in this order:
cf-turnstile-response— what Cloudflare auto-injects.h-captcha-response— what hCaptcha auto-injects.captcha_token— generic alias if you set the token manually.
The token is stripped from the stored payload, so it never appears in your dashboard, email notifications, or webhook deliveries.
How verification works
When a submission arrives, we:
- Pull the form's captcha provider and encrypted secret from the database.
- Decrypt the secret with our AES-256-GCM key.
- POST the token, secret, and submitter IP to the provider's
siteverifyendpoint:- Turnstile:
https://challenges.cloudflare.com/turnstile/v0/siteverify - hCaptcha:
https://api.hcaptcha.com/siteverify
- Turnstile:
- If the provider returns
success: true, the submission proceeds. Otherwise we return403 Forbidden.
The verify call has a 10-second timeout. Network errors return 403 CaptchaVerifyFailed so you can retry from the client.
Error responses
| Status | error |
When |
|---|---|---|
403 |
CaptchaMissing |
Captcha is enabled but no token field was present in the payload. |
403 |
CaptchaFailed |
Provider returned success: false. Token is invalid, expired, or replayed. |
403 |
CaptchaNotConfigured |
Captcha is set in the dashboard but no secret key is on file. |
403 |
CaptchaVerifyFailed |
Network error reaching the provider's verify endpoint. |
Sample error body:
{
"statusCode": 403,
"error": "CaptchaFailed",
"message": "Captcha verification failed"
}
Testing in development
Both providers ship test keys that always pass — no real captcha challenge is shown. Use these on localhost:
Cloudflare Turnstile (docs):
| Site key | Secret key | Behavior |
|---|---|---|
1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
Always passes |
2x00000000000000000000AB |
2x0000000000000000000000000000000AA |
Always blocks |
3x00000000000000000000FF |
1x0000000000000000000000000000000AA |
Forces an interactive challenge |
hCaptcha (docs):
| Site key | Secret key | Behavior |
|---|---|---|
10000000-ffff-ffff-ffff-000000000001 |
0x0000000000000000000000000000000000000000 |
Always passes |
Paste the test keys into your dev workspace's form, swap them for the production keys before going live.
Troubleshooting
CaptchaMissing even though I see the widget
Check your browser's network tab and confirm cf-turnstile-response (or h-captcha-response) is in the POST body. If you're submitting via fetch with JSON, you need to read the token from the widget callback and add it manually — the auto-inject only happens for native <form> submissions.
CaptchaFailed in production but works locally
The token is bound to the domain registered in Cloudflare/hCaptcha. Add your production domain to the widget config. Tokens also expire after ~5 minutes — if your form sits idle, ask the user to re-verify.
CaptchaNotConfigured
You set the provider in Settings → Security but didn't save a secret key, or the encryption key on the server has changed and old ciphertexts can't be decrypted. Re-paste the secret in the dashboard.
Honeypot still firing
Captcha and the honeypot field run independently. If botcheck (or whatever you set as the honeypot field name) is non-empty, the submission is silently marked as spam regardless of whether the captcha passed. Make sure your form doesn't auto-fill the honeypot.
Self-hosting note
If you self-host TruForms, captcha verification uses the provider's public siteverify endpoint — no extra environment variables required. Just make sure your API container can reach challenges.cloudflare.com and api.hcaptcha.com outbound on port 443.
Further reading
- Cloudflare Turnstile documentation — client widget, server-side validation, and error codes.
- hCaptcha documentation — integration, scoring, and the test keys referenced above.