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.
The library is open source — grab it on GitHub or install via npm.
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
You can either download the file from GitHub and drop it alongside validation.js:
contact_form/
├── index.html
├── zoho-turnstile.js ← new
├── css/
│ └── form.css
└── js/
└── validation.js
Or load it directly from the CDN — no download required:
<script src="https://unpkg.com/zoho-turnstile/zoho-turnstile.js"></script>
Step 3 — Initialise the library
Add the script to index.html inside <head>. Use either the local file or the CDN version from Step 2:
<script src="js/validation.js"></script>
<!-- Local file -->
<script src="zoho-turnstile.js"></script>
<!-- Or CDN -->
<script src="https://unpkg.com/zoho-turnstile/zoho-turnstile.js"></script>
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 — wrapped in DOMContentLoaded to guarantee the library
// has executed before init is called, regardless of the host environment
document.addEventListener('DOMContentLoaded', function () {
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.
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) {
// ── Only accept POST ───────────────────────────────────────────────
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const referer = request.headers.get('Referer') || '';
// ── Parse incoming form data ───────────────────────────────────────
let formData;
try {
formData = await request.formData();
} catch {
return errorRedirect(referer, 'invalid_request');
}
// ── Extract and verify the Turnstile token ─────────────────────────
const token = formData.get('cf-turnstile-response');
if (!token) {
return errorRedirect(referer, 'missing_token');
}
const turnstileOk = await verifyTurnstile(
token,
request.headers.get('CF-Connecting-IP'),
env.TURNSTILE_SECRET_KEY
);
if (!turnstileOk) {
return errorRedirect(referer, 'token_invalid');
}
// ── Strip the Turnstile field — Zoho doesn't need it ──────────────
formData.delete('cf-turnstile-response');
// ── Forward to Zoho ────────────────────────────────────────────────
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');
}
// ── Success — redirect back to the form page with success param ────
const successUrl = new URL(referer);
successUrl.searchParams.set('zf_status', 'success');
successUrl.searchParams.delete('zf_error');
return Response.redirect(successUrl.toString(), 302);
}
};
// ── Helpers ────────────────────────────────────────────────────────────────────
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) {
if (!referer) {
return new Response(JSON.stringify({ error: reason }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const url = new URL(referer);
url.searchParams.set('zf_status', 'error');
url.searchParams.set('zf_error', reason);
return Response.redirect(url.toString(), 302);
}
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.
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"
...
>
Pass verifyUrl to the init call
document.addEventListener('DOMContentLoaded', function () {
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
[Cloudflare Turnstile] Error: 110200
The sitekey doesn't match the domain the form is being served from. Confirm the domain registered in the Turnstile dashboard matches exactly where the form is hosted. If testing locally, use one of the Cloudflare test keys from the Testing section above — your production sitekey will always fail on localhost or file://.
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)