Zoho Forms lets you export your form as a standalone HTML package — but that exported form posts directly to Zoho's servers with no spam protection. This guide walks you through adding Cloudflare Turnstile using a single drop-in script, with optional server-side verification via a Cloudflare Worker.
What we're building
Browser → Turnstile challenge → token issued
→ form submitted → Cloudflare Worker
→ token verified server-side
→ payload forwarded to Zoho
The library (zoho-turnstile.js) works in two modes:
- Client-only — blocks submissions with no token. Stops browser-based bots. No backend needed.
- Full verification — routes submissions through a Cloudflare Worker that verifies the token before anything reaches Zoho. Stops everything.
⚠️ Client-only mode cannot stop direct POST attacks. For production forms, full verification is strongly recommended.
Prerequisites
- A Cloudflare account (free tier is fine)
- A Zoho Form exported as HTML (
index.html,css/form.css,js/validation.js) - A domain registered under Cloudflare (for full verification)
Step 1 — Get a Cloudflare Turnstile Site Key
- Go to dash.cloudflare.com → Turnstile → Add Site
- Enter your domain
- Choose widget type Managed
- Copy the Site Key and Secret Key — you'll need both
Step 2 — Add zoho-turnstile.js to your form export
Drop zoho-turnstile.js into your form folder alongside validation.js:
contact_form/
├── index.html
├── zoho-turnstile.js ← new
├── css/
│ └── form.css
└── js/
└── validation.js
Step 3 — Initialise the library
Add the script to index.html inside <head>:
<script src="js/validation.js"></script>
<script src="zoho-turnstile.js"></script> <!-- add this -->
Then initialise it before </body>, after your existing Zoho init vars:
<script>
// Your existing Zoho vars — already in your export, don't duplicate
var zf_MandArray = ["SingleLine", "Email", "MultiLine"];
var zf_FieldArray = ["SingleLine", "Email", "MultiLine"];
var isSalesIQIntegrationEnabled = false;
var salesIQFieldsArray = [];
// Turnstile init
ZohoTurnstile.init({
siteKey: 'YOUR_SITE_KEY'
});
</script>
That's it for client-only mode. The widget will appear above your submit button automatically.
Step 4 (optional) — Add server-side verification via Cloudflare Worker
Skip this if you're using client-only mode. If you're going to production, don't skip this.
4a — Create the Worker
- In the Cloudflare dashboard → Workers & Pages → Create → Start with Hello World
- Name it
zoho-contact-proxyand click Deploy - Click Edit Code, delete the default code, paste the Worker code below, click Deploy
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const referer = request.headers.get('Referer') || '/';
let formData;
try {
formData = await request.formData();
} catch {
return errorRedirect(referer, 'invalid_request');
}
const token = formData.get('cf-turnstile-response');
if (!token) return errorRedirect(referer, 'missing_token');
const verified = await verifyTurnstile(
token,
request.headers.get('CF-Connecting-IP'),
env.TURNSTILE_SECRET_KEY
);
if (!verified) return errorRedirect(referer, 'token_invalid');
formData.delete('cf-turnstile-response');
try {
const zohoRes = await fetch(env.ZOHO_FORM_URL, {
method: 'POST',
body: formData,
redirect: 'follow',
});
if (!zohoRes.ok) return errorRedirect(referer, 'submission_failed');
} catch {
return errorRedirect(referer, 'submission_failed');
}
const successUrl = new URL(referer);
successUrl.searchParams.set('zf_status', 'success');
return Response.redirect(successUrl.toString(), 302);
}
};
async function verifyTurnstile(token, ip, secret) {
try {
const res = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token, remoteip: ip }),
});
const data = await res.json();
return data.success === true;
} catch {
return false;
}
}
function errorRedirect(referer, reason) {
const url = new URL(referer);
url.searchParams.set('zf_status', 'error');
url.searchParams.set('zf_error', reason);
return Response.redirect(url.toString(), 302);
}
4b — Add environment variables
In the Worker → Settings → Variables and Secrets:
| Name | Type | Value |
|---|---|---|
ZOHO_FORM_URL |
Text | Your Zoho form htmlRecords/submit URL |
TURNSTILE_SECRET_KEY |
Secret | Your Turnstile Secret Key |
⚠️ Always add
TURNSTILE_SECRET_KEYas type Secret, never Text. Secrets are encrypted and never visible after saving.
4c — Point the form at the Worker
In index.html, change the form action from the Zoho URL to your Worker URL:
<form
action="https://zoho-contact-proxy.yourname.workers.dev"
method="POST"
enctype="multipart/form-data"
...
>
4d — Pass verifyUrl to the init call
ZohoTurnstile.init({
siteKey: 'YOUR_SITE_KEY',
verifyUrl: 'https://zoho-contact-proxy.yourname.workers.dev'
});
Options reference
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
siteKey |
string | ✅ | — | Your Cloudflare Turnstile Site Key |
verifyUrl |
string | — | null |
Worker base URL. Enables full server-side verification |
theme |
string | — | 'light' |
'light' \ |
size |
string | — | 'normal' |
'normal' \ |
errorMessage |
string | — | 'Please complete the security check...' |
Shown when submission is blocked |
containerId |
string | — | null |
Custom element ID to render the widget into |
onSuccess |
function | — | null |
Fired when Turnstile issues a token. Receives the token as the first argument |
onError |
function | — | null |
Fired when Turnstile encounters an error |
onExpire |
function | — | null |
Fired when an issued token expires (tokens are valid for 5 minutes) |
Methods
ZohoTurnstile.init(options)
Initialises the library. Injects the widget and patches the Zoho submit handler. Call once after the DOM is ready.
ZohoTurnstile.reset()
Resets the widget and issues a new challenge. Call this after a failed submission.
ZohoTurnstile.getToken()
Returns the current token string, or empty string if not yet issued. Useful for debugging.
Testing
Cloudflare provides special site keys for development:
| Behaviour | Site Key |
|---|---|
| Always passes | 1x00000000000000000000AA |
| Always blocks | 2x00000000000000000000AB |
| Forces interactive challenge | 3x00000000000000000000FF |
Use 3x00000000000000000000FF during development — it forces the checkbox interaction so you're not silently pre-cleared on your own IP.
Verify the token is being issued — after solving the challenge, open DevTools → Elements and look inside the form for:
<input type="hidden" name="cf-turnstile-response" value="eyJ...">
Or run in the console:
ZohoTurnstile.getToken() // should return a non-empty string
Verify server-side is working — submit the form and check the redirect URL. You should land back on the page with ?zf_status=success.
Troubleshooting
Widget doesn't appear
Check the browser console for [ZohoTurnstile] warnings. Confirm your domain is registered in the Turnstile dashboard and that zoho-turnstile.js loads before the init call.
Form submits without solving the challenge
You're being silently pre-cleared — Cloudflare automatically passes visitors it already trusts. This is expected. Use test key 3x00000000000000000000FF to force an interactive challenge.
?zf_status=error&zf_error=token_invalid
The Worker received the submission but Cloudflare rejected the token. Confirm TURNSTILE_SECRET_KEY in the Worker matches the Secret Key (not Site Key) from the dashboard. Tokens also expire after 5 minutes — have the user refresh and resubmit.
?zf_status=error&zf_error=submission_failed
Token verified but the Zoho POST failed. Confirm ZOHO_FORM_URL is the full htmlRecords/submit URL from your form export. Check Worker logs under Workers → your worker → Logs.
Top comments (0)