DEV Community

Silly Coder
Silly Coder

Posted on

Adding Cloudflare Turnstile to Zoho Form HTML Exports

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Go to dash.cloudflare.comTurnstileAdd Site
  2. Enter your domain
  3. Choose widget type Managed
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 -->
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

  1. In the Cloudflare dashboard → Workers & PagesCreateStart with Hello World
  2. Name it zoho-contact-proxy and click Deploy
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

4b — Add environment variables

In the Worker → SettingsVariables 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_KEY as 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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

4d — Pass verifyUrl to the init call

ZohoTurnstile.init({
  siteKey:   'YOUR_SITE_KEY',
  verifyUrl: 'https://zoho-contact-proxy.yourname.workers.dev'
});
Enter fullscreen mode Exit fullscreen mode

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...">
Enter fullscreen mode Exit fullscreen mode

Or run in the console:

ZohoTurnstile.getToken() // should return a non-empty string
Enter fullscreen mode Exit fullscreen mode

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)