DEV Community

Silly Coder
Silly Coder

Posted on • Edited 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.

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
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

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

Or load it directly from the CDN — no download required:

<script src="https://unpkg.com/zoho-turnstile/zoho-turnstile.js"></script>
Enter fullscreen mode Exit fullscreen mode

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>
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 — 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>
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.

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) {

    // ── 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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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

Pass verifyUrl to the init call

document.addEventListener('DOMContentLoaded', function () {
  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

[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)